diff --git a/Dockerfile b/Dockerfile index 0c78c39681..3d87a8eb47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-alpine as base +FROM quay.io/hotosm/base-python-image as base LABEL version=0.1 LABEL maintainer="HOT Sysadmin " LABEL description="Builds backend docker image" diff --git a/backend/api/organisations/resources.py b/backend/api/organisations/resources.py index 499b7f79ff..46e5d36a88 100644 --- a/backend/api/organisations/resources.py +++ b/backend/api/organisations/resources.py @@ -323,12 +323,11 @@ def patch(self, organisation_id): try: organisation_dto = UpdateOrganisationDTO(request.get_json()) organisation_dto.organisation_id = organisation_id - # Don't update organisation type if user is not admin + # Don't update organisation type and subscription_tier if request user is not an admin if User.get_by_id(token_auth.current_user()).role != 1: - org_type = OrganisationService.get_organisation_by_id( - organisation_id - ).type - organisation_dto.type = OrganisationType(org_type).name + org = OrganisationService.get_organisation_by_id(organisation_id) + organisation_dto.type = OrganisationType(org.type).name + organisation_dto.subscription_tier = org.subscription_tier organisation_dto.validate() except DataError as e: current_app.logger.error(f"error validating request: {str(e)}") diff --git a/backend/api/users/resources.py b/backend/api/users/resources.py index 7aacdde9e6..86789bccf3 100644 --- a/backend/api/users/resources.py +++ b/backend/api/users/resources.py @@ -20,7 +20,7 @@ def get(self, user_id): parameters: - in: header name: Authorization - description: Base64 encoded sesesion token + description: Base64 encoded session token required: true type: string default: Token sessionTokenHere== @@ -41,7 +41,9 @@ def get(self, user_id): description: Internal Server Error """ try: - user_dto = UserService.get_user_dto_by_id(user_id) + user_dto = UserService.get_user_dto_by_id( + user_id, token_auth.current_user() + ) return user_dto.to_primitive(), 200 except NotFound: return {"Error": "User not found"}, 404 diff --git a/backend/models/dtos/mapping_dto.py b/backend/models/dtos/mapping_dto.py index 170a2ffa9a..4380e1aa5a 100644 --- a/backend/models/dtos/mapping_dto.py +++ b/backend/models/dtos/mapping_dto.py @@ -90,6 +90,7 @@ class TaskDTO(Model): last_updated = UTCDateTimeType( serialized_name="lastUpdated", serialize_when_none=False ) + comments_number = IntType(serialized_name="numberOfComments") class TaskDTOs(Model): diff --git a/backend/models/dtos/organisation_dto.py b/backend/models/dtos/organisation_dto.py index acd30fdef0..36288f970e 100644 --- a/backend/models/dtos/organisation_dto.py +++ b/backend/models/dtos/organisation_dto.py @@ -56,6 +56,7 @@ class OrganisationDTO(Model): teams = ListType(ModelType(OrganisationTeamsDTO)) campaigns = ListType(ListType(StringType)) type = StringType(validators=[is_known_organisation_type]) + subscription_tier = IntType(serialized_name="subscriptionTier") class ListOrganisationsDTO(Model): @@ -77,6 +78,7 @@ class NewOrganisationDTO(Model): description = StringType() url = StringType() type = StringType(validators=[is_known_organisation_type]) + subscription_tier = IntType(serialized_name="subscriptionTier") class UpdateOrganisationDTO(OrganisationDTO): diff --git a/backend/models/dtos/team_dto.py b/backend/models/dtos/team_dto.py index 8756268a75..634baf442f 100644 --- a/backend/models/dtos/team_dto.py +++ b/backend/models/dtos/team_dto.py @@ -71,6 +71,7 @@ def __init__(self): team_id = IntType(serialized_name="teamId") organisation_id = IntType(required=True) organisation = StringType(required=True) + organisation_slug = StringType(serialized_name="organisationSlug") name = StringType(required=True) logo = StringType() description = StringType() diff --git a/backend/models/postgis/organisation.py b/backend/models/postgis/organisation.py index fe376c78a3..4cce5d6b0b 100644 --- a/backend/models/postgis/organisation.py +++ b/backend/models/postgis/organisation.py @@ -41,6 +41,7 @@ class Organisation(db.Model): description = db.Column(db.String) url = db.Column(db.String) type = db.Column(db.Integer, default=OrganisationType.FREE.value, nullable=False) + subscription_tier = db.Column(db.Integer) managers = db.relationship( User, @@ -67,6 +68,7 @@ def create_from_dto(cls, new_organisation_dto: NewOrganisationDTO): new_org.description = new_organisation_dto.description new_org.url = new_organisation_dto.url new_org.type = OrganisationType[new_organisation_dto.type].value + new_org.subscription_tier = new_organisation_dto.subscription_tier for manager in new_organisation_dto.managers: user = User.get_by_username(manager) @@ -174,6 +176,7 @@ def as_dto(self, omit_managers=False): organisation_dto.url = self.url organisation_dto.managers = [] organisation_dto.type = OrganisationType(self.type).name + organisation_dto.subscription_tier = self.subscription_tier if omit_managers: return organisation_dto diff --git a/backend/models/postgis/task.py b/backend/models/postgis/task.py index c2f27dad7f..5ba62eb463 100644 --- a/backend/models/postgis/task.py +++ b/backend/models/postgis/task.py @@ -1001,6 +1001,7 @@ def as_dto( self, task_history: List[TaskHistoryDTO] = [], last_updated: datetime.datetime = None, + comments: int = None, ): """Just converts to a TaskDTO""" task_dto = TaskDTO() @@ -1011,6 +1012,7 @@ def as_dto( task_dto.task_history = task_history task_dto.last_updated = last_updated if last_updated else None task_dto.auto_unlock_seconds = Task.auto_unlock_delta().total_seconds() + task_dto.comments_number = comments if type(comments) == int else None return task_dto def as_dto_with_instructions(self, preferred_locale: str = "en") -> TaskDTO: diff --git a/backend/models/postgis/user.py b/backend/models/postgis/user.py index 4c910de3c7..192e4767a4 100644 --- a/backend/models/postgis/user.py +++ b/backend/models/postgis/user.py @@ -372,16 +372,16 @@ def as_dto(self, logged_in_username: str) -> UserDTO: user_dto.comments_notifications = self.comments_notifications user_dto.tasks_notifications = self.tasks_notifications user_dto.teams_notifications = self.teams_notifications - gender = None - if self.gender is not None: - gender = UserGender(self.gender).name - user_dto.gender = gender - user_dto.self_description_gender = self.self_description_gender if self.username == logged_in_username: - # Only return email address when logged in user is looking at their own profile + # Only return email address and gender information when logged in user is looking at their own profile user_dto.email_address = self.email_address user_dto.is_email_verified = self.is_email_verified + gender = None + if self.gender is not None: + gender = UserGender(self.gender).name + user_dto.gender = gender + user_dto.self_description_gender = self.self_description_gender return user_dto def create_or_update_interests(self, interests_ids): diff --git a/backend/services/team_service.py b/backend/services/team_service.py index 265893046c..1a9ba51061 100644 --- a/backend/services/team_service.py +++ b/backend/services/team_service.py @@ -303,6 +303,7 @@ def get_team_as_dto( team_dto.logo = team.organisation.logo team_dto.organisation = team.organisation.name team_dto.organisation_id = team.organisation.id + team_dto.organisation_slug = team.organisation.slug if user_id != 0: if UserService.is_user_an_admin(user_id): diff --git a/backend/services/users/user_service.py b/backend/services/users/user_service.py index 413fddf012..111925b6a9 100644 --- a/backend/services/users/user_service.py +++ b/backend/services/users/user_service.py @@ -181,11 +181,12 @@ def get_user_dto_by_username( return requested_user.as_dto(logged_in_user.username) @staticmethod - def get_user_dto_by_id(requested_user: int) -> UserDTO: + def get_user_dto_by_id(requested_user: int, logged_in_user: int) -> UserDTO: """Gets user DTO for supplied user id """ requested_user = UserService.get_user_by_id(requested_user) + logged_in_user = UserService.get_user_by_id(logged_in_user) - return requested_user.as_dto(requested_user.username) + return requested_user.as_dto(logged_in_user.username) @staticmethod def get_interests_stats(user_id): @@ -237,9 +238,9 @@ def get_tasks_dto( ) -> UserTaskDTOs: base_query = ( TaskHistory.query.with_entities( - TaskHistory.project_id, - TaskHistory.task_id, - func.max(TaskHistory.action_date), + TaskHistory.project_id.label("project_id"), + TaskHistory.task_id.label("task_id"), + func.max(TaskHistory.action_date).label("max"), ) .filter(TaskHistory.user_id == user_id) .group_by(TaskHistory.task_id, TaskHistory.project_id) @@ -264,14 +265,37 @@ def get_tasks_dto( user_task_dtos = UserTaskDTOs() task_id_list = base_query.subquery() + comments_query = ( + TaskHistory.query.with_entities( + TaskHistory.project_id, + TaskHistory.task_id, + func.count(TaskHistory.action).label("count"), + ) + .filter(TaskHistory.action == "COMMENT") + .group_by(TaskHistory.task_id, TaskHistory.project_id) + ).subquery() + + sq = ( + db.session.query( + func.coalesce(comments_query.c.count, 0).label("comments"), task_id_list + ) + .select_from(task_id_list) + .outerjoin( + comments_query, + (comments_query.c.task_id == task_id_list.c.task_id) + & (comments_query.c.project_id == task_id_list.c.project_id), + ) + .subquery() + ) + tasks = Task.query.join( - task_id_list, + sq, and_( - Task.id == task_id_list.c.task_id, - Task.project_id == task_id_list.c.project_id, + Task.id == sq.c.task_id, + Task.project_id == sq.c.project_id, ), ) - tasks = tasks.add_column("max_1") + tasks = tasks.add_columns("max", "comments") if project_status: tasks = tasks.filter( @@ -286,8 +310,8 @@ def get_tasks_dto( task_list = [] - for task, action_date in results.items: - task_list.append(task.as_dto(last_updated=action_date)) + for task, action_date, comments in results.items: + task_list.append(task.as_dto(last_updated=action_date, comments=comments)) user_task_dtos.user_tasks = task_list user_task_dtos.pagination = Pagination(results) diff --git a/frontend/package.json b/frontend/package.json index 6ec9571a9f..744399c51f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,11 +4,11 @@ "license": "BSD-2-Clause", "private": false, "dependencies": { - "@formatjs/intl-locale": "^2.4.19", - "@formatjs/intl-pluralrules": "^4.0.11", - "@formatjs/intl-relativetimeformat": "^8.1.2", + "@formatjs/intl-locale": "^2.4.20", + "@formatjs/intl-pluralrules": "^4.0.12", + "@formatjs/intl-relativetimeformat": "^8.1.3", "@formatjs/macro": "^0.2.8", - "@hotosm/id": "^2.19.5", + "@hotosm/id": "^2.19.6", "@hotosm/iso-countries-languages": "^1.1.0", "@lokibai/react-use-copy-clipboard": "^1.0.4", "@mapbox/geo-viewport": "^0.4.1", @@ -17,8 +17,8 @@ "@mapbox/mapbox-gl-language": "^0.10.1", "@mapbox/togeojson": "^0.16.0", "@reach/router": "^1.3.4", - "@sentry/react": "^6.2.1", - "@sentry/tracing": "^6.2.1", + "@sentry/react": "^6.2.2", + "@sentry/tracing": "^6.2.2", "@turf/area": "^6.3.0", "@turf/bbox": "^6.3.0", "@turf/bbox-polygon": "^6.3.0", @@ -30,10 +30,10 @@ "@webscopeio/react-textarea-autocomplete": "^4.7.3", "axios": "^0.21.1", "chart.js": "^2.9.4", - "date-fns": "^2.18.0", - "dompurify": "^2.2.6", + "date-fns": "^2.19.0", + "dompurify": "^2.2.7", "downshift-hooks": "^0.8.1", - "final-form": "^4.20.1", + "final-form": "^4.20.2", "fromentries": "^1.3.2", "humanize-duration": "^3.25.1", "immutable": "^4.0.0-rc.12", @@ -56,7 +56,7 @@ "react-placeholder": "^4.1.0", "react-redux": "^7.2.2", "react-scripts": "^4.0.3", - "react-select": "^4.1.0", + "react-select": "^4.2.1", "react-tooltip": "^4.2.13", "reactjs-popup": "^1.5.0", "redux": "^4.0.5", @@ -68,12 +68,12 @@ "tachyons": "^4.12.0", "use-query-params": "^1.2.0", "webfontloader": "^1.6.28", - "workbox-core": "^6.1.1", - "workbox-expiration": "^6.1.1", - "workbox-precaching": "^6.1.1", - "workbox-recipes": "^6.1.1", - "workbox-routing": "^6.1.1", - "workbox-strategies": "^6.1.1" + "workbox-core": "^6.1.2", + "workbox-expiration": "^6.1.2", + "workbox-precaching": "^6.1.2", + "workbox-recipes": "^6.1.2", + "workbox-routing": "^6.1.2", + "workbox-strategies": "^6.1.2" }, "scripts": { "build-locales": "combine-messages -i './src/**/messages.js' -o './src/locales/en.json'", @@ -107,10 +107,10 @@ "@testing-library/jest-dom": "^5.11.9", "@testing-library/react": "^11.2.4", "@testing-library/react-hooks": "^5.1.0", - "@testing-library/user-event": "^12.8.1", + "@testing-library/user-event": "^12.8.3", "combine-react-intl-messages": "^4.0.0", "jest-canvas-mock": "^2.3.1", - "msw": "^0.27.0", + "msw": "^0.27.1", "prettier": "^2.2.1", "react-select-event": "^5.2.0", "react-test-renderer": "^17.0.1", diff --git a/frontend/src/components/comments/commentInput.js b/frontend/src/components/comments/commentInput.js index 8db4770428..de53f80048 100644 --- a/frontend/src/components/comments/commentInput.js +++ b/frontend/src/components/comments/commentInput.js @@ -22,7 +22,7 @@ export const CommentInputField = ({ comment, setComment, enableHashtagPaste = fa return (
setComment(e.target.value)} token={token} @@ -40,7 +40,7 @@ export const CommentInputField = ({ comment, setComment, enableHashtagPaste = fa ); }; -export const UserFetchTextarea = ({ value, setValueFn, token }) => { +export const UserFetchTextarea = ({ value, setValueFn, token, inputProps }) => { const fetchUsers = async (user) => { const url = `users/queries/filter/${user}/`; let userItems; @@ -59,6 +59,7 @@ export const UserFetchTextarea = ({ value, setValueFn, token }) => { return (
+ {numberOfComments ? ( + + + {numberOfComments} + + ) : ( + '' + )} diff --git a/frontend/src/components/contributions/tests/taskCard.test.js b/frontend/src/components/contributions/tests/taskCard.test.js index e0e316b9bb..c38aa319e3 100644 --- a/frontend/src/components/contributions/tests/taskCard.test.js +++ b/frontend/src/components/contributions/tests/taskCard.test.js @@ -7,8 +7,8 @@ import { ReduxIntlProviders } from '../../../utils/testWithIntl'; import { TaskCard } from '../taskCard'; describe('TaskCard', () => { - it('on MAPPED state', () => { - render( + it('on MAPPED state with comments', () => { + const { container } = render( { taskStatus={'MAPPED'} lockHolder={null} taskHistory={[]} + numberOfComments={5} lastUpdated={'2021-01-22T12:59:37.238281Z'} /> , @@ -23,14 +24,16 @@ describe('TaskCard', () => { expect(screen.getByText('Task #987 · Project #4321')).toBeInTheDocument(); expect(screen.getByText(/Last updated/)).toBeInTheDocument(); expect(screen.getByText('Ready for validation')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(container.querySelectorAll('svg').length).toBe(2); expect(screen.queryByText('Resume task')).not.toBeInTheDocument(); // hovering on the card should not change anything userEvent.hover(screen.getByText('Ready for validation')); expect(screen.queryByText('Resume task')).not.toBeInTheDocument(); }); - it('on VALIDATED state', () => { - render( + it('on VALIDATED state without comments', () => { + const { container } = render( { taskStatus={'VALIDATED'} lockHolder={null} taskHistory={[]} + commentsNumber={0} lastUpdated={'2021-01-22T12:59:37.238281Z'} /> , ); expect(screen.getByText('Finished')).toBeInTheDocument(); expect(screen.queryByText('Resume task')).not.toBeInTheDocument(); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + expect(container.querySelectorAll('svg').length).toBe(1); // hovering on the card should not change anything userEvent.hover(screen.getByText('Finished')); expect(screen.queryByText('Resume task')).not.toBeInTheDocument(); diff --git a/frontend/src/components/projectCreate/index.js b/frontend/src/components/projectCreate/index.js index b42a021555..5193c565d2 100644 --- a/frontend/src/components/projectCreate/index.js +++ b/frontend/src/components/projectCreate/index.js @@ -7,10 +7,12 @@ import bbox from '@turf/bbox'; import { featureCollection } from '@turf/helpers'; import lineToPolygon from '@turf/line-to-polygon'; import { useQueryParam, NumberParam } from 'use-query-params'; -import { FormattedMessage, FormattedNumber } from 'react-intl'; +import { FormattedMessage, FormattedNumber, useIntl } from 'react-intl'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; - +import { createProject } from '../../store/actions/project'; +import { store } from '../../store'; +import { pushToLocalJSONAPI } from '../../network/genericJSONRequest'; import messages from './messages'; import SetAOI from './setAOI'; import SetTaskSizes from './setTaskSizes'; @@ -20,7 +22,8 @@ import Review from './review'; import { AlertMessage } from './alertMessage'; import { fetchLocalJSONAPI } from '../../network/genericJSONRequest'; import { MAX_AOI_AREA } from '../../config'; - +import { navigate } from '@reach/router'; +import truncate from '@turf/truncate'; import { makeGrid } from './setTaskSizes'; import { MAX_FILESIZE } from '../../config'; @@ -68,6 +71,8 @@ export const addLayer = (layerName, data, map) => { }; const ProjectCreate = (props) => { + const intl = useIntl(); + const token = useSelector((state) => state.auth.get('token')); const [drawModeIsActive, setDrawModeIsActive] = useState(false); const layer_name = 'aoi'; @@ -292,6 +297,38 @@ const ProjectCreate = (props) => { draw: new MapboxDraw(drawOptions), }); + const handleCreate = useCallback((cloneProjectData) => { + if (!metadata.geom) { + setErr({error: true, message: intl.formatMessage(messages.noGeometry)}); + return; + } + if (!metadata.organisation && !cloneProjectData.organisation) { + setErr({error: true, message:intl.formatMessage(messages.noOrganization)}); + return; + } + + store.dispatch(createProject(metadata)); + let projectParams = { + areaOfInterest: truncate(metadata.geom, { precision: 6 }), + projectName: metadata.projectName, + organisation: metadata.organisation || cloneProjectData.organisation, + tasks: truncate(metadata.taskGrid, { precision: 6 }), + arbitraryTasks: metadata.arbitraryTasks, + }; + + if (cloneProjectData.name !== null) { + projectParams.projectName = ''; + projectParams.cloneFromProjectId = cloneProjectData.id; + } + pushToLocalJSONAPI('projects/', JSON.stringify(projectParams), token) + .then((res) => navigate(`/manage/projects/${res.projectId}`)) + .catch((e) => setErr({ + error: true, + message: , + }) ); + }, [metadata, setErr, intl, token]); + + if (!token) { return ; } @@ -356,7 +393,6 @@ const ProjectCreate = (props) => { )} {renderCurrentStep()} - { updateMetadata={updateMetadata} maxArea={MAX_AOI_AREA} setErr={setErr} + cloneProjectData={cloneProjectData} + handleCreate={() => handleCreate(cloneProjectData)} />
diff --git a/frontend/src/components/projectCreate/navButtons.js b/frontend/src/components/projectCreate/navButtons.js index 7176051bf5..3594da5b14 100644 --- a/frontend/src/components/projectCreate/navButtons.js +++ b/frontend/src/components/projectCreate/navButtons.js @@ -84,7 +84,17 @@ const NavButtons = (props) => { )} - {props.index === 4 ? null : ( + {props.index === 4 ? ( + ) : ( diff --git a/frontend/src/components/projectCreate/review.js b/frontend/src/components/projectCreate/review.js index 111dbc3b0b..6f169cc960 100644 --- a/frontend/src/components/projectCreate/review.js +++ b/frontend/src/components/projectCreate/review.js @@ -1,50 +1,13 @@ -import React, { useState, useCallback } from 'react'; -import { navigate } from '@reach/router'; -import truncate from '@turf/truncate'; -import { FormattedMessage, useIntl } from 'react-intl'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; import messages from './messages'; -import { Button } from '../button'; import { AlertMessage } from './alertMessage'; -import { createProject } from '../../store/actions/project'; -import { store } from '../../store'; -import { pushToLocalJSONAPI } from '../../network/genericJSONRequest'; + import { OrganisationSelect } from '../formInputs'; export default function Review({ metadata, updateMetadata, token, projectId, cloneProjectData }) { const [error, setError] = useState(null); - const intl = useIntl(); - - const handleCreate = useCallback( - (metadata, token, cloneProjectData, setError) => { - if (!metadata.geom) { - setError(intl.formatMessage(messages.noGeometry)); - return; - } - if (!metadata.organisation && !cloneProjectData.organisation) { - setError(intl.formatMessage(messages.noOrganization)); - return; - } - - store.dispatch(createProject(metadata)); - let projectParams = { - areaOfInterest: truncate(metadata.geom, { precision: 6 }), - projectName: metadata.projectName, - organisation: metadata.organisation || cloneProjectData.organisation, - tasks: truncate(metadata.taskGrid, { precision: 6 }), - arbitraryTasks: metadata.arbitraryTasks, - }; - - if (cloneProjectData.name !== null) { - projectParams.projectName = ''; - projectParams.cloneFromProjectId = cloneProjectData.id; - } - pushToLocalJSONAPI('projects/', JSON.stringify(projectParams), token) - .then((res) => navigate(`/manage/projects/${res.projectId}`)) - .catch((e) => setError(e.Error)); - }, - [intl], - ); const setProjectName = (event) => { event.preventDefault(); @@ -93,18 +56,6 @@ export default function Review({ metadata, updateMetadata, token, projectId, clo ) : null} -
- -
{error && (
- - {totalContributors || 0}, - }} - /> + + {totalContributors ? ( + {chunks}, + }} + /> + ) : ( + + )}
diff --git a/frontend/src/components/projectDetail/messages.js b/frontend/src/components/projectDetail/messages.js index 10afd312ae..62481306d8 100644 --- a/frontend/src/components/projectDetail/messages.js +++ b/frontend/src/components/projectDetail/messages.js @@ -20,9 +20,13 @@ export default defineMessages({ id: 'project.detail.createdBy', defaultMessage: 'Project created by {user}.', }, + noProjectContributors: { + id: 'project.detail.contributorCount.zero', + defaultMessage: 'No contributors yet', + }, projectTotalContributors: { id: 'project.detail.contributorCount', - defaultMessage: '{number} total contributors', + defaultMessage: '{number, plural, one {# contributor} other {# contributors}}', }, projectLastContribution: { id: 'project.detail.lastContribution', diff --git a/frontend/src/components/projectDetail/newMapperFlow.js b/frontend/src/components/projectDetail/newMapperFlow.js deleted file mode 100644 index 478261e788..0000000000 --- a/frontend/src/components/projectDetail/newMapperFlow.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import messages from './messages'; -import { TaskSelectionIcon, AreaIcon, SubmitWorkIcon } from '../svgIcons'; - -function MappingCard({ image, title, description }: Object) { - return ( -
-
-
-
{image}
-

- -

-

- -

-
-
-
- ); -} - -export function NewMapperFlow() { - const style = { height: '5rem', width: '6rem' }; - const cards = [ - { - image: , - title: messages.selectATaskCardTitle, - description: messages.selectATaskCardDescription, - }, - { - image: , - title: messages.mapThroughOSMCardTitle, - description: messages.mapThroughOSMCardDescription, - }, - { - image: , - title: messages.submitYourWorkCardTitle, - description: messages.submitYourWorkCardDescription, - }, - ]; - return ( -
-
- {cards.map((card, n) => ( - - ))} -
-
-
- ); -} diff --git a/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js b/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js new file mode 100644 index 0000000000..721bfbdf0b --- /dev/null +++ b/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { BigProjectTeaser } from '../bigProjectTeaser'; +import { IntlProviders } from '../../../utils/testWithIntl'; + +describe('BigProjectTeaser component', () => { + it('shows 5 total contributors for project last updated 1 minute ago', () => { + render( + + + , + ); + expect(screen.queryByText('5')).toBeInTheDocument(); + expect(screen.getByText(/contributors/)).toBeInTheDocument(); + expect(screen.getByText(/Last contribution 1 minute ago/)).toBeInTheDocument(); + }); + + it('shows 1 total contributor for project last updated a second ago', () => { + render( + + + , + ); + expect(screen.queryByText('1')).toBeInTheDocument(); + expect(screen.getByText(/contributor/)).toBeInTheDocument(); + expect(screen.getByText(/Last contribution 1 second ago/)).toBeInTheDocument(); + }); + + it('shows no contributors yet for project with no mapping or validation and last updated 4 days ago', () => { + render( + + + , + ); + expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument(); + expect(screen.getByText(/Last contribution 4 days ago/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/downloadButtons.test.js b/frontend/src/components/projectDetail/tests/downloadButtons.test.js new file mode 100644 index 0000000000..38dfc4dcb5 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/downloadButtons.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { DownloadAOIButton, DownloadTaskGridButton } from '../downloadButtons'; +import { IntlProviders } from '../../../utils/testWithIntl'; + +describe('tests DownloadAOI and DownloadTasksGrid buttons', () => { + it('displays button to download AOI for project with id 1', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('a').href).toContain('projects/1/queries/aoi/?as_file=true'); + expect(container.querySelector('a').download).toBe('project-1-aoi.geojson'); + expect(screen.getByText(/Download AOI/)).toBeInTheDocument(); + expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument(); + }); + + it('displays button to download Task Grid for project with id 2', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('a').href).toContain('projects/2/tasks/?as_file=true'); + expect(container.querySelector('a').download).toBe('project-2-tasks.geojson'); + expect(screen.getByText(/Download Tasks Grid/)).toBeInTheDocument(); + expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/favorites.test.js b/frontend/src/components/projectDetail/tests/favorites.test.js new file mode 100644 index 0000000000..7049676009 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/favorites.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { AddToFavorites } from '../favorites'; + +describe('AddToFavorites button', () => { + it('renders button when project id = 1', async () => { + const props = { + projectId: 1, + }; + const { container } = render( + + + , + ); + const button = screen.getByRole('button'); + expect(button.className).toBe(' input-reset base-font bg-white blue-dark f6 bn pointer'); + expect(button.className).not.toBe('dn input-reset base-font bg-white blue-dark f6 bn pointer'); + expect(container.querySelector('svg').classList.value).toBe('pt3 pr2 v-btm o-50 '); + expect(button.textContent).toBe('Add to Favorites'); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/footer.test.js b/frontend/src/components/projectDetail/tests/footer.test.js new file mode 100644 index 0000000000..c8306d447f --- /dev/null +++ b/frontend/src/components/projectDetail/tests/footer.test.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { ProjectDetailFooter } from '../footer'; + +describe('test if project detail footer', () => { + const props = { + projectId: 1, + className: '', + }; + it('renders footer for project with id 1', () => { + render( + + + , + ); + expect(screen.getByText(/Overview/)).toBeInTheDocument(); + expect(screen.getByText(/Description/)).toBeInTheDocument(); + expect(screen.getByText(/Coordination/)).toBeInTheDocument(); + expect(screen.getByText(/Teams & Permissions/)).toBeInTheDocument(); + expect(screen.getByText(/Questions and comments/)).toBeInTheDocument(); + expect(screen.getByText(/Contributions/)).toBeInTheDocument(); + expect(screen.getByText('Share')).toBeInTheDocument(); + const contributeBtn = screen.getByRole('button', { pressed: false }); + const link = contributeBtn.closest('a'); + expect(link.href).toContain('/tasks'); + expect(contributeBtn.textContent).toBe('Contribute'); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/header.test.js b/frontend/src/components/projectDetail/tests/header.test.js new file mode 100644 index 0000000000..9aad24de40 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/header.test.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { HeaderLine, ProjectHeader } from '../header'; +import { ReduxIntlProviders, IntlProviders } from '../../../utils/testWithIntl'; +import { getProjectSummary } from '../../../network/tests/mockData/projects'; +import { store } from '../../../store'; + +describe('test if HeaderLine component', () => { + it('shows id 2 and HIGH priority status for a HOT project to a user with edit rights', () => { + render( + + + , + ); + + expect(screen.getByText('#2')).toBeInTheDocument(); + expect(screen.getByText('#2').closest('a').href).toContain('projects/2'); + expect(screen.getByText('| HOT')).toBeInTheDocument(); + expect(screen.getByText('Edit project')).toBeInTheDocument(); + expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/2'); + expect(screen.getByText('High')).toBeInTheDocument(); + }); + + it('shows id 1 for a LOW priority HOT project to a user with no edit rights', () => { + render( + + + , + ); + + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('#1').closest('a').href).toContain('projects/1'); + expect(screen.getByText('| HOT')).toBeInTheDocument(); + expect(screen.queryByText('Edit project')).not.toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); + }); +}); + +describe('test if ProjectHeader component', () => { + const project = getProjectSummary(1); + it('shows Header for urgent priority project for logged in project author', () => { + act(() => { + store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' }); + store.dispatch({ + type: 'SET_USER_DETAILS', + userDetails: { username: 'test_user' }, + }); + }); + render( + + + , + ); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('#1').closest('a').href).toContain('projects/1'); + expect(screen.getByText('| HOT')).toBeInTheDocument(); + expect(screen.getByText('Edit project')).toBeInTheDocument(); + expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/1'); + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en'); + expect(screen.getByText('Environment Conservation')).toBeInTheDocument(); + expect(screen.getByText('Women security')).toBeInTheDocument(); + expect(screen.getByText('Bolivia')).toBeInTheDocument(); + }); + + it('shows Header for urgent priority project for non-logged in user', () => { + act(() => { + store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' }); + }); + render( + + + , + ); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('#1').closest('a').href).toContain('projects/1'); + expect(screen.getByText('| HOT')).toBeInTheDocument(); + expect(screen.queryByText('Edit project')).not.toBeInTheDocument(); + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en'); + expect(screen.getByText('Environment Conservation')).toBeInTheDocument(); + expect(screen.getByText('Women security')).toBeInTheDocument(); + expect(screen.getByText('Bolivia')).toBeInTheDocument(); + }); + + it('shows Header for low priority draft project for logged in user', () => { + act(() => { + store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' }); + store.dispatch({ + type: 'SET_USER_DETAILS', + userDetails: { username: 'user123' }, + }); + }); + render( + + + , + ); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('#1').closest('a').href).toContain('projects/1'); + expect(screen.getByText('| HOT')).toBeInTheDocument(); + expect(screen.queryByText('Edit project')).not.toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); //LOW priority tag should not be displayed + expect(screen.queryByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings')).toBeInTheDocument(); + expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en'); + expect(screen.getByText('Environment Conservation')).toBeInTheDocument(); + expect(screen.getByText('Bolivia')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/infoPanel.test.js b/frontend/src/components/projectDetail/tests/infoPanel.test.js new file mode 100644 index 0000000000..291f8f5d99 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/infoPanel.test.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ProjectInfoPanel } from '../infoPanel'; +import { IntlProviders } from '../../../utils/testWithIntl'; +import { getProjectSummary } from '../../../network/tests/mockData/projects'; + +describe('if projectInfoPanel', () => { + const contributors = [ + { + username: 'test_user', + mappingLevel: 'ADVANCED', + pictureUrl: null, + mapped: 2, + validated: 0, + total: 2, + mappedTasks: [1, 2], + validatedTasks: [], + name: 'Test', + dateRegistered: new Date(), + }, + ]; + + const tasks = { + features: [ + { + geometry: { + coordinates: [ + [ + [ + [-71.485823338, 1.741751328], + [-71.485899664, 1.741550711], + [-71.485954761, 1.741751328], + [-71.485823338, 1.741751328], + ], + ], + ], + type: 'MultiPolygon', + }, + properties: { + lockedBy: null, + taskId: 2, + taskIsSquare: false, + taskStatus: 'MAPPED', + taskX: 158035, + taskY: 264680, + taskZoom: 19, + }, + type: 'Feature', + }, + { + geometry: { + coordinates: [ + [ + [ + [-71.485686012, 1.742112276], + [-71.485823338, 1.741751328], + [-71.485954761, 1.741751328], + [-71.48597717, 1.741832923], + [-71.48597717, 1.741970313], + [-71.485686012, 1.742112276], + ], + ], + ], + type: 'MultiPolygon', + }, + properties: { + lockedBy: null, + taskId: 3, + taskIsSquare: false, + taskStatus: 'READY', + taskX: 158035, + taskY: 264681, + taskZoom: 19, + }, + type: 'Feature', + }, + ], + type: 'FeatureCollection', + }; + + const project = { ...getProjectSummary(2), lastUpdated: Date.now() - 1e3 * 60 * 60 }; + + it('renders panel for a beginner mapper project with 1 contributor using any imagery', () => { + render( + + + , + ); + + expect(screen.getByText('Types of Mapping')).toBeInTheDocument(); + expect(screen.getByText('Imagery')).toBeInTheDocument(); + expect(screen.queryByText('Any available source')).toBeInTheDocument(); + expect(screen.queryByText('1')).toBeInTheDocument(); + expect(screen.queryByText('contributor')).toBeInTheDocument(); + expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument(); + expect(screen.queryByText('Beginner mapper')).toBeInTheDocument(); + }); + + it('renders new immediate mapper project with no contributors yet and using Custom imagery', () => { + render( + + + , + ); + expect(screen.getByText('Types of Mapping')).toBeInTheDocument(); + expect(screen.getByText('Imagery')).toBeInTheDocument(); + expect(screen.queryByText('Custom')).toBeInTheDocument(); + expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument(); + expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument(); + expect(screen.queryByText('Intermediate mapper')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/questionsAndComments.test.js b/frontend/src/components/projectDetail/tests/questionsAndComments.test.js new file mode 100644 index 0000000000..6be1eee0d3 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/questionsAndComments.test.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { store } from '../../../store'; + +import { ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { QuestionsAndComments } from '../questionsAndComments'; + +describe('test if QuestionsAndComments component', () => { + it('only renders text asking user to log in for non-logged in user', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('p').textContent).toBe( + 'You need to log in to be able to post comments.', + ); + }); + + it('enables logged in user to post and view comments', async () => { + jest.spyOn(window, 'fetch'); + act(() => { + store.dispatch({ type: 'SET_TOKEN', token: '123456' }); + }); + render( + + + , + ); + + window.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + chat: [ + { + message: '

Test comment

', + pictureUrl: null, + timestamp: Date.now() - 1e3, + username: 'TestUser', + length: 1, + }, + ], + pagination: { + hasNext: false, + hasPrev: false, + nextNum: null, + page: 1, + pages: 1, + perPage: 5, + }, + }), + }); + expect( + screen.queryByText( + 'There are currently no questions or comments on this project. Be the first to post one!', + ), + ).toBeInTheDocument(); + const textarea = screen.getByRole('textbox'); + expect(textarea.textContent).toBe(''); + const button = screen.getByRole('button'); + expect(button.textContent).toBe('Post'); + + // type comment in textbox + fireEvent.change(textarea, { target: { value: 'Test comment' } }); + expect(textarea.textContent).toBe('Test comment'); + expect( + screen.queryByTitle('Add "#managers" to notify the project managers about your comment.') + .textContent, + ).toBe('#managers'); + expect( + screen.queryByTitle('Add "#author" to notify the project author about your comment.') + .textContent, + ).toBe('#author'); + + // click button to post comment + fireEvent.click(button); + expect(screen.getByText('Sending message...')).toBeInTheDocument(); + await waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(2); + }); + expect(screen.getByRole('link').href).toContain('/users/TestUser'); + expect(screen.getByRole('link').textContent).toBe('TestUser'); + expect(screen.getByText('Test comment')).toBeInTheDocument(); //posted comment + expect(screen.getByText('1 second ago')).toBeInTheDocument(); //time posted + expect(screen.getByText('1')).toBeInTheDocument(); //page button + expect(screen.getByText('Message sent.')).toBeInTheDocument(); + expect(textarea.textContent).toBe(''); //empty textarea + expect( + screen.queryByText( + 'There are currently no questions or comments on this project. Be the first to post one!', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/shareButton.test.js b/frontend/src/components/projectDetail/tests/shareButton.test.js new file mode 100644 index 0000000000..a93f0811f0 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/shareButton.test.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { IntlProviders } from '../../../utils/testWithIntl'; +import { ShareButton } from '../shareButton'; + +describe('test if shareButton', () => { + it('render shareButton for project with id 1', () => { + const { container } = render( + + + , + ); + expect(screen.getByText('Share')).toBeInTheDocument(); + expect(screen.getByText('Tweet')).toBeInTheDocument(); + expect(screen.getByText('Post on Facebook')).toBeInTheDocument(); + expect(screen.getByText('Share on LinkedIn')).toBeInTheDocument(); + + const svg = container.querySelectorAll('svg'); + expect(svg.length).toBe(4); + + const socialIconLabels = []; + svg.forEach((icon) => { + if (icon.attributes.getNamedItem('aria-label')) { + socialIconLabels.push(icon.attributes.getNamedItem('aria-label').value); + } + }); + expect(socialIconLabels).toMatchObject(['Twitter', 'Facebook', 'LinkedIn']); + }); +}); diff --git a/frontend/src/components/projectDetail/tests/statusBox.test.js b/frontend/src/components/projectDetail/tests/statusBox.test.js new file mode 100644 index 0000000000..8f29a2f323 --- /dev/null +++ b/frontend/src/components/projectDetail/tests/statusBox.test.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ProjectStatusBox } from '../statusBox'; +import { IntlProviders } from '../../../utils/testWithIntl'; + +describe('test if ProjectStatusBox component', () => { + it('displays the DRAFT status as orange', () => { + render( + + + , + ); + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('Draft').className).toContain('b--orange orange'); + expect(screen.getByText('Draft').className).not.toContain('b--blue-grey blue-grey'); + }); + + it('displays ARCHIVED status as grey', () => { + render( + + + , + ); + expect(screen.getByText('Archived')).toBeInTheDocument(); + expect(screen.getByText('Archived').className).toContain('b--blue-grey blue-grey'); + expect(screen.getByText('Archived').className).not.toContain('b--orange orange'); + }); +}); diff --git a/frontend/src/components/projectStats/contributorsStats.js b/frontend/src/components/projectStats/contributorsStats.js index a72b54de46..a7a6880a5c 100644 --- a/frontend/src/components/projectStats/contributorsStats.js +++ b/frontend/src/components/projectStats/contributorsStats.js @@ -7,7 +7,7 @@ import userMessages from '../user/messages'; import { CHART_COLOURS } from '../../config'; import { formatChartData, formatTooltip } from '../../utils/formatChartJSData'; import { useContributorStats } from '../../hooks/UseContributorStats'; -import { StatsCardContent } from '../statsCardContent'; +import { StatsCardContent } from '../statsCard'; export default function ContributorsStats({ contributors }) { const intl = useIntl(); @@ -70,17 +70,17 @@ export default function ContributorsStats({ contributors }) { } - className="pv3-l pv2 mb3 shadow-4 bg-white" + className="pv3-l pv2 mb3-l mb2 shadow-4 bg-white" /> } - className="pv3-l pv2 mb3 shadow-4 bg-white" + className="pv3-l pv2 mb3-l mb2 shadow-4 bg-white" /> } - className="pv3-l pv2 mb3 shadow-4 bg-white" + className="pv3-l pv2 mb3-l mb2 shadow-4 bg-white" />
diff --git a/frontend/src/components/projectStats/edits.js b/frontend/src/components/projectStats/edits.js index e11685b44b..c28d22f4bf 100644 --- a/frontend/src/components/projectStats/edits.js +++ b/frontend/src/components/projectStats/edits.js @@ -4,66 +4,45 @@ import { FormattedMessage } from 'react-intl'; import projectMessages from './messages'; import userDetailMessages from '../userDetail/messages'; import { MappingIcon, HomeIcon, RoadIcon, EditIcon } from '../svgIcons'; -import { StatsCardContent } from '../statsCardContent'; - -const getFieldData = (field) => { - const iconClass = 'h-50 w-50'; - const iconStyle = { height: '45px' }; - switch (field) { - case 'buildings': - return { - icon: , - message: , - }; - case 'roads': - return { - icon: , - message: , - }; - case 'changesets': - return { - icon: , - message: , - }; - case 'edits': - return { - icon: , - message: , - }; - default: - return null; - } -}; - -const Element = ({ field, value }) => { - const element = getFieldData(field); - return ( -
-
-
{element.icon}
- -
-
- ); -}; +import { StatsCard } from '../statsCard'; export const EditsStats = ({ data }) => { const { changesets, buildings, roads, edits } = data; + const iconClass = 'h-50 w-50'; + const iconStyle = { height: '45px' }; + return (

- - - - + } + description={} + value={changesets || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={edits || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={buildings || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={roads || 0} + className={'w-25-l w-50-m w-100 mv1'} + />
); diff --git a/frontend/src/components/projectStats/taskStatus.js b/frontend/src/components/projectStats/taskStatus.js index 5b6f59d2ce..d54efdd31e 100644 --- a/frontend/src/components/projectStats/taskStatus.js +++ b/frontend/src/components/projectStats/taskStatus.js @@ -6,7 +6,7 @@ import statusMessages from '../taskSelection/messages'; import messages from './messages'; import { formatChartData, formatTooltip } from '../../utils/formatChartJSData'; import { TASK_COLOURS } from '../../config'; -import { StatsCardContent } from '../statsCardContent'; +import { StatsCardContent } from '../statsCard'; const TasksByStatus = ({ stats }) => { const intl = useIntl(); diff --git a/frontend/src/components/projectStats/tests/edits.test.js b/frontend/src/components/projectStats/tests/edits.test.js index 9abfa0a9d9..0be367784e 100644 --- a/frontend/src/components/projectStats/tests/edits.test.js +++ b/frontend/src/components/projectStats/tests/edits.test.js @@ -28,6 +28,9 @@ describe('EditsStats component', () => { expect(getByText('Buildings mapped')).toBeInTheDocument(); expect(getByText('Km road mapped')).toBeInTheDocument(); expect(getByText('Total map edits')).toBeInTheDocument(); - expect(getByText('310483')).toBeInTheDocument(); + expect(getByText('310,483')).toBeInTheDocument(); + expect(getByText('22,153')).toBeInTheDocument(); + expect(getByText('2,739')).toBeInTheDocument(); + expect(getByText('269,809')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/projectStats/timeStats.js b/frontend/src/components/projectStats/timeStats.js index c3954e491c..fe076034d6 100644 --- a/frontend/src/components/projectStats/timeStats.js +++ b/frontend/src/components/projectStats/timeStats.js @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl'; import messages from './messages'; import { useFetch } from '../../hooks/UseFetch'; import { shortEnglishHumanizer } from '../userDetail/elementsMapped'; -import { StatsCardContent } from '../statsCardContent'; +import { StatsCardContent } from '../statsCard'; import { MappedIcon, ValidatedIcon } from '../svgIcons'; const StatsRow = ({ stats }) => { @@ -25,7 +25,7 @@ const StatsRow = ({ stats }) => { return (
{fields.map((t, n) => ( -
+
{t.indexOf('Mapping') !== -1 ? ( diff --git a/frontend/src/components/projects/myProjectNav.js b/frontend/src/components/projects/myProjectNav.js index 12a8461d16..a8437ca9c5 100644 --- a/frontend/src/components/projects/myProjectNav.js +++ b/frontend/src/components/projects/myProjectNav.js @@ -31,7 +31,7 @@ export const MyProjectNav = (props) => { return (
-

+

{props.management ? ( ) : ( diff --git a/frontend/src/components/statsCard.js b/frontend/src/components/statsCard.js new file mode 100644 index 0000000000..f67db44156 --- /dev/null +++ b/frontend/src/components/statsCard.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedNumber } from 'react-intl'; + +export const StatsCard = ({ icon, description, value, className, invertColors = false }) => { + return ( +
+
+
{icon}
+ : value + } + label={description} + className="w-70 w-100-m pt3-m mb1 fl tc" + invertColors={invertColors} + /> +
+
+ ); +}; + +export const StatsCardContent = ({ value, label, className, invertColors = false }: Object) => ( +
+

{value}

+ {label} +
+); diff --git a/frontend/src/components/statsCardContent.js b/frontend/src/components/statsCardContent.js deleted file mode 100644 index 33bf6fc1a7..0000000000 --- a/frontend/src/components/statsCardContent.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; - -export const StatsCardContent = ({ value, label, className, invertColors = false }: Object) => ( -
-

{value}

- {label} -
-); diff --git a/frontend/src/components/svgIcons/comment.js b/frontend/src/components/svgIcons/comment.js new file mode 100644 index 0000000000..d533fdff61 --- /dev/null +++ b/frontend/src/components/svgIcons/comment.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 CommentIcon extends React.PureComponent { + render() { + return ( + + ); + } +} diff --git a/frontend/src/components/svgIcons/index.js b/frontend/src/components/svgIcons/index.js index def5eca976..40e5b8852f 100644 --- a/frontend/src/components/svgIcons/index.js +++ b/frontend/src/components/svgIcons/index.js @@ -79,3 +79,4 @@ export { FourCellsGridIcon, NineCellsGridIcon } from './grid'; export { CutIcon } from './cut'; export { FileImportIcon } from './fileImport'; export { CalendarIcon } from './calendar'; +export { CommentIcon } from './comment'; diff --git a/frontend/src/components/taskSelection/action.js b/frontend/src/components/taskSelection/action.js index c4fdaa2216..861c45949c 100644 --- a/frontend/src/components/taskSelection/action.js +++ b/frontend/src/components/taskSelection/action.js @@ -340,10 +340,10 @@ export function TaskMapAction({ project, projectIsReady, tasks, activeTasks, act )}
-

#{project.projectId}

+

#{project.projectId}

{tasksIds.map((task, n) => ( - {`#${task}`} + {`#${task}`} ))}
diff --git a/frontend/src/components/teamsAndOrgs/featureStats.js b/frontend/src/components/teamsAndOrgs/featureStats.js index 8f1a4a8ef1..6b6e8f9968 100644 --- a/frontend/src/components/teamsAndOrgs/featureStats.js +++ b/frontend/src/components/teamsAndOrgs/featureStats.js @@ -1,8 +1,11 @@ import React, { useEffect, useState } from 'react'; import axios from 'axios'; +import { FormattedMessage } from 'react-intl'; +import userDetailMessages from '../userDetail/messages'; import { HOMEPAGE_STATS_API_URL } from '../../config'; -import { Element } from '../userDetail/elementsMapped'; +import { RoadIcon, HomeIcon, WavesIcon, MarkerIcon } from '../svgIcons'; +import { StatsCard } from '../statsCard'; export const FeatureStats = () => { const [stats, setStats] = useState({ edits: 0, buildings: 0, roads: 0, pois: 0, waterways: 0 }); @@ -25,12 +28,35 @@ export const FeatureStats = () => { getStats(); }, []); + const iconClass = 'h-50 w-50'; + const iconStyle = { height: '45px' }; + return (
- - - - + } + description={} + value={stats.buildings || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={stats.roads || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={stats.pois || 0} + className={'w-25-l w-50-m w-100 mv1'} + /> + } + description={} + value={stats.waterways || 0} + className={'w-25-l w-50-m w-100 mv1'} + />
); }; diff --git a/frontend/src/components/teamsAndOrgs/management.js b/frontend/src/components/teamsAndOrgs/management.js index 04f6d1a7f1..bfbfdc1147 100644 --- a/frontend/src/components/teamsAndOrgs/management.js +++ b/frontend/src/components/teamsAndOrgs/management.js @@ -58,7 +58,7 @@ export function Management(props) { return (
-

{props.title}

+

{props.title}

{props.showAddButton && ( diff --git a/frontend/src/components/teamsAndOrgs/messages.js b/frontend/src/components/teamsAndOrgs/messages.js index 6144914e33..e669de8774 100644 --- a/frontend/src/components/teamsAndOrgs/messages.js +++ b/frontend/src/components/teamsAndOrgs/messages.js @@ -16,6 +16,10 @@ export default defineMessages({ id: 'management.fields.managers', defaultMessage: 'Managers', }, + noManagers: { + id: 'management.fields.managers.empty', + defaultMessage: 'There are no managers yet.', + }, manage: { id: 'management.link.manage', defaultMessage: 'Manage {entity}', @@ -32,6 +36,10 @@ export default defineMessages({ id: 'management.members', defaultMessage: 'Members', }, + noMembers: { + id: 'management.members.empty', + defaultMessage: 'There are no members yet.', + }, mappingTeams: { id: 'management.teams.mapping', defaultMessage: 'Mapping teams', @@ -62,7 +70,7 @@ export default defineMessages({ }, noRequests: { id: 'management.teams.join_requests.empty', - defaultMessage: "There isn't requests to join the team.", + defaultMessage: "There aren't any requests to join the team.", }, teams: { id: 'management.teams', @@ -164,6 +172,10 @@ export default defineMessages({ id: 'management.organisations.publicUrl.copy', defaultMessage: 'Copy public URL', }, + selectTier: { + id: 'management.organisations.tier.select', + defaultMessage: 'Select tier', + }, selectType: { id: 'management.organisations.type.select', defaultMessage: 'Select type', @@ -180,6 +192,10 @@ export default defineMessages({ id: 'management.organisations.type.defaultFee', defaultMessage: 'Default fee', }, + noOrganisationsFound: { + id: 'management.organisations.list.empty', + defaultMessage: 'No organizations were found.', + }, retry: { id: 'management.organisations.stats.retry', defaultMessage: 'Try again', @@ -225,6 +241,10 @@ export default defineMessages({ defaultMessage: '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.', }, + subscribedTier: { + id: 'management.organisations.stats.tier.subscribed', + defaultMessage: 'Subscribed tier', + }, levelTooltip: { id: 'management.organisations.stats.level.tooltip', defaultMessage: '{n} of {total} ({percent}%) completed to move to level {nextLevel}', @@ -255,7 +275,7 @@ export default defineMessages({ }, actionsToNextTier: { id: 'management.organisations.stats.next_tier.actions', - defaultMessage: 'Actions to reach the next tier', + defaultMessage: 'Actions to move to the next tier', }, freeTier: { id: 'management.organisations.tier.free', diff --git a/frontend/src/components/teamsAndOrgs/orgUsageLevel.js b/frontend/src/components/teamsAndOrgs/orgUsageLevel.js index f46f34646c..2943eaafc0 100644 --- a/frontend/src/components/teamsAndOrgs/orgUsageLevel.js +++ b/frontend/src/components/teamsAndOrgs/orgUsageLevel.js @@ -3,7 +3,7 @@ import { getYear } from 'date-fns'; import { FormattedMessage, FormattedNumber } from 'react-intl'; import messages from './messages'; -import { StatsCardContent } from '../statsCardContent'; +import { StatsCardContent } from '../statsCard'; import { ProgressBar } from '../progressBar'; import { usePredictYearlyTasks } from '../../hooks/UsePredictYearlyTasks'; import { diff --git a/frontend/src/components/teamsAndOrgs/organisations.js b/frontend/src/components/teamsAndOrgs/organisations.js index 74671dfaaf..15e641f28d 100644 --- a/frontend/src/components/teamsAndOrgs/organisations.js +++ b/frontend/src/components/teamsAndOrgs/organisations.js @@ -10,6 +10,7 @@ import { FormattedMessage, useIntl } from 'react-intl'; import messages from './messages'; import { IMAGE_UPLOAD_SERVICE } from '../../config'; import { useUploadImage } from '../../hooks/UseUploadImage'; +import { levels } from '../../hooks/UseOrganisationLevel'; import { Management } from './management'; import { InternalLinkIcon, ClipboardIcon } from '../svgIcons'; import { Button } from '../button'; @@ -38,7 +39,13 @@ export function OrgsManagement({ isAdmin={isAdmin} > {isOrgManager ? ( - organisations.map((org, n) => ) + organisations.length ? ( + organisations.map((org, n) => ) + ) : ( +
+ +
+ ) ) : (
@@ -103,7 +110,10 @@ export function OrganisationForm(props) {

- +
@@ -132,7 +142,17 @@ export function OrganisationForm(props) { ); } -export function OrgInformation({ hasSlug }) { +const TYPE_OPTIONS = [ + { label: , value: 'FREE' }, + { label: , value: 'DISCOUNTED' }, + { label: , value: 'FULL_FEE' }, +]; +const TIER_OPTIONS = levels.map((level) => ({ + label: , + value: level.level, +})); + +export function OrgInformation({ hasSlug, formState }) { const token = useSelector((state) => state.auth.get('token')); const userDetails = useSelector((state) => state.auth.get('userDetails')); const [uploadError, uploading, uploadImg] = useUploadImage(); @@ -142,16 +162,17 @@ export function OrgInformation({ hasSlug }) { const [isCopied, setCopied] = useCopyClipboard(); const labelClasses = 'db pt3 pb2'; const fieldClasses = 'blue-grey w-100 pv3 ph2 input-reset ba b--grey-light bg-transparent'; - const typeOptions = [ - { label: , value: 'FREE' }, - { label: , value: 'DISCOUNTED' }, - { label: , value: 'FULL_FEE' }, - ]; + const getTypePlaceholder = (value) => { - const selected = typeOptions.filter((type) => value === type.value); + const selected = TYPE_OPTIONS.filter((type) => value === type.value); return selected.length ? selected[0].label : ; }; + const getTierPlaceholder = (value) => { + const selected = TIER_OPTIONS.filter((tier) => value === tier.value); + return selected.length ? selected[0].label : ; + }; + return ( <>
@@ -206,24 +227,45 @@ export function OrgInformation({ hasSlug }) {
{userDetails && - userDetails.role === 'ADMIN' && ( // only admin users can edit the org type -
- - - {(props) => ( - props.input.onChange(value.value)} + className="z-5" + /> + )} + +
+ {['DISCOUNTED', 'FULL_FEE'].includes(formState.type) && ( +
+ + + {(props) => ( +