diff --git a/backend/routes/incidents.py b/backend/routes/incidents.py index 9f3bf48a..1a0488b2 100644 --- a/backend/routes/incidents.py +++ b/backend/routes/incidents.py @@ -52,8 +52,8 @@ def create_incident(): class SearchIncidentsSchema(BaseModel): location: Optional[str] = None - startTime: Optional[datetime] = None - endTime: Optional[datetime] = None + dateStart: Optional[str] = None + dateEnd: Optional[str] = None description: Optional[str] = None page: Optional[int] = 1 perPage: Optional[int] = 20 @@ -63,9 +63,9 @@ class Config: schema_extra = { "example": { "description": "Test description", - "endTime": "2019-12-01 00:00:00", + "dateEnd": "2019-12-01", "location": "Location 1", - "startTime": "2019-09-01 00:00:00", + "dateStart": "2019-09-01", } } @@ -86,10 +86,14 @@ def search_incidents(): # TODO: eventually replace with geosearch. Geocode records and integrate # PostGIS query = query.filter(Incident.location.ilike(f"%{body.location}%")) - if body.startTime: - query = query.filter(Incident.time_of_incident >= body.startTime) - if body.endTime: - query = query.filter(Incident.time_of_incident <= body.endTime) + if body.dateStart: + query = query.filter( + Incident.time_of_incident >= + datetime.strptime(body.dateStart, "%Y-%m-%d")) + if body.dateEnd: + query = query.filter( + Incident.time_of_incident <= + datetime.strptime(body.dateEnd, "%Y-%m-%d")) if body.description: query = query.filter( Incident.description.ilike(f"%{body.description}%") diff --git a/backend/tests/test_incidents.py b/backend/tests/test_incidents.py index 60b789ab..c23768b8 100644 --- a/backend/tests/test_incidents.py +++ b/backend/tests/test_incidents.py @@ -111,8 +111,8 @@ def test_get_incident(app, client, db_session, access_token): ), ( { - "startTime": "2021-09-30 00:00:00", - "endTime": "2021-10-02 00:00:00", + "dateStart": "2021-09-30", + "dateEnd": "2021-10-02", }, ["traffic"], ), diff --git a/frontend/compositions/search-panel/search-panel.tsx b/frontend/compositions/search-panel/search-panel.tsx index c196bc17..be1dc052 100644 --- a/frontend/compositions/search-panel/search-panel.tsx +++ b/frontend/compositions/search-panel/search-panel.tsx @@ -3,8 +3,9 @@ import { FormProvider, useForm } from "react-hook-form" import { useAuth, useSearch } from "../../helpers" import { searchPanelInputs, SearchTypes, ToggleOptions } from "../../models" -import { FormLevelError, PrimaryButton, PrimaryInput, ToggleBox } from "../../shared-components" +import { FormLevelError, PrimaryButton, PrimaryInput, SecondaryInput, ToggleBox } from "../../shared-components" import styles from "./search.module.css" +import SecondaryInputStories from "../../shared-components/secondary-input/secondary-input.stories" const { searchPanelContainer, searchForm } = styles @@ -28,13 +29,14 @@ export const SearchPanel = () => { setFormInputs(searchPanelInputs[e.target.value as SearchTypes]) } - async function onSubmit({ location, startTime, endTime, description }: any) { + async function onSubmit({ location, dateEnd, dateStart, description, source }: any) { setIsLoading(true) try { - await searchIncidents({ accessToken, description, endTime, location, startTime }) + await searchIncidents({ accessToken, description, dateEnd, location, dateStart, source }) } catch (e) { console.error("Unexpected search error", e) setErrorMessage("Something went wrong. Please try again.") + /* # TODO: Add error handling when a 401 is recieved. Redirect to login */ } setIsLoading(false) } @@ -54,6 +56,7 @@ export const SearchPanel = () => { formInputs.map((inputName) => ( ))} + {errorMessage && } diff --git a/frontend/helpers/api/api.ts b/frontend/helpers/api/api.ts index b32f5faa..44d34a37 100644 --- a/frontend/helpers/api/api.ts +++ b/frontend/helpers/api/api.ts @@ -82,9 +82,10 @@ export type LoginRequest = LoginCredentials export type WhoamiRequest = AuthenticatedRequest export interface IncidentSearchRequest extends AuthenticatedRequest { description?: string - startTime?: string - endTime?: string + dateStart?: string + dateEnd?: string location?: string + source?: string page?: number perPage?: number } diff --git a/frontend/models/app-routes.tsx b/frontend/models/app-routes.tsx index d53d6089..c5adcca4 100644 --- a/frontend/models/app-routes.tsx +++ b/frontend/models/app-routes.tsx @@ -1,4 +1,5 @@ export enum AppRoutes { + CONTRIBUTOR = "/contributor", DASHBOARD = "/search", FORGOT = "/forgot", RESET = "/reset", diff --git a/frontend/models/enrollment-cta.tsx b/frontend/models/enrollment-cta.tsx index dd21efde..60da98ab 100644 --- a/frontend/models/enrollment-cta.tsx +++ b/frontend/models/enrollment-cta.tsx @@ -8,6 +8,7 @@ interface CallToActionText { export enum CallToActionTypes { LOGIN = "login", + CONTRIBUTOR = "contributor", REGISTER = "register", DASHBOARD = "dashboard", FORGOT = "forgot", @@ -30,6 +31,11 @@ export const enrollmentCallToActionText: { [key in CallToActionTypes]: CallToAct linkText: "Return to dashboard", linkPath: AppRoutes.DASHBOARD }, + [CallToActionTypes.CONTRIBUTOR]: { + description: "Want to report an issue the police in your area?", + linkText: "Become a Contributor", + linkPath: AppRoutes.CONTRIBUTOR + }, [CallToActionTypes.FORGOT]: { //description: "New to the National Police Data Coalition?", linkText: "Forgot your password?", diff --git a/frontend/models/index.tsx b/frontend/models/index.tsx index 0ed41234..d1f56ef5 100644 --- a/frontend/models/index.tsx +++ b/frontend/models/index.tsx @@ -4,6 +4,7 @@ export * from "./info-tooltip" export * from "./logo-sizes" export * from "./password-aid" export * from "./primary-input" +export * from "./secondary-input" export * from "./response" export * from "./results-alert" export * from "./saved-table" diff --git a/frontend/models/primary-input.tsx b/frontend/models/primary-input.tsx index 7ffad2f0..b407331f 100644 --- a/frontend/models/primary-input.tsx +++ b/frontend/models/primary-input.tsx @@ -9,11 +9,15 @@ export enum PrimaryInputNames { EMAIL_ADDRESS = "emailAddress", FIRST_NAME = "firstName", INCIDENT_TYPE = "incidentType", + DESCRIPTION = "description", KEY_WORDS = "keyWords", LAST_NAME = "lastName", LOCATION = "location", LOGIN_PASSWORD = "loginPassword", OFFICER_NAME = "officerName", + ORGANIZATION_NAME = "organizationName", + ORGANIZATION_URL = "organizationUrl", + ORGANIZATION_EMAIL = "organizationEmail", PHONE_NUMBER = "phoneNumber", STREET_ADDRESS = "streetAddress", ZIP_CODE = "zipCode" @@ -23,6 +27,9 @@ const passwordRgx: RegExp = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d\s]). const nameRgx: RegExp = new RegExp("^[' -]*[a-z]+[a-z' -]+$", "i") const anyString: RegExp = new RegExp("[sS]*") const phoneNumberRgx: RegExp = /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/ +const emailRgx: RegExp = /^[a-z0-9_.-]+@[a-z0-9_.-]+\.[a-z]{2,4}$/i +const urlRgx: RegExp = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/ +const dateRgx: RegExp = /^\d{4}-\d{2}-\d{2}$/ export const primaryInputValidation = { [PrimaryInputNames.BADGE_NUMBER]: { @@ -47,23 +54,23 @@ export const primaryInputValidation = { inputType: "password" }, [PrimaryInputNames.DATE]: { - errorMessage: "Date", - pattern: anyString, + errorMessage: "Enter a valid date in the format YYYY-MM-DD.", + pattern: dateRgx, inputType: "date" }, [PrimaryInputNames.DATE_START]: { - errorMessage: "Enter a date", - pattern: anyString, + errorMessage: "Enter a valid date in the format YYYY-MM-DD.", + pattern: dateRgx, inputType: "date" }, [PrimaryInputNames.DATE_END]: { - errorMessage: "Enter a date", - pattern: anyString, + errorMessage: "Enter a valid date in the format YYYY-MM-DD.", + pattern: dateRgx, inputType: "date" }, [PrimaryInputNames.EMAIL_ADDRESS]: { errorMessage: "Please enter a valid email", - pattern: /^[a-z0-9_.-]+@[a-z0-9_.-]+\.[a-z]{2,4}$/i, + pattern: emailRgx, inputType: "email" }, [PrimaryInputNames.FIRST_NAME]: { @@ -72,7 +79,12 @@ export const primaryInputValidation = { inputType: "text" }, [PrimaryInputNames.INCIDENT_TYPE]: { - errorMessage: "A name requires 2+ letters", + errorMessage: "Must include at least 2 characters.", + pattern: anyString, + inputType: "text" + }, + [PrimaryInputNames.DESCRIPTION]: { + errorMessage: "Must include at least 2 characters.", pattern: anyString, inputType: "text" }, @@ -81,6 +93,21 @@ export const primaryInputValidation = { pattern: nameRgx, inputType: "text" }, + [PrimaryInputNames.ORGANIZATION_NAME]: { + errorMessage: "Must include at least 2 characters.", + pattern: nameRgx, + inputType: "text" + }, + [PrimaryInputNames.ORGANIZATION_URL]: { + errorMessage: "Must include at least 2 characters.", + pattern: urlRgx, + inputType: "text" + }, + [PrimaryInputNames.ORGANIZATION_EMAIL]: { + errorMessage: "Please enter a valid email", + pattern: emailRgx, + inputType: "text" + }, [PrimaryInputNames.PHONE_NUMBER]: { errorMessage: "A valid phone number is required", pattern: phoneNumberRgx, @@ -125,13 +152,16 @@ export enum SearchTypes { export const searchPanelInputs: { [key in SearchTypes]: PrimaryInputNames[] } = { [SearchTypes.INCIDENTS]: [ + PrimaryInputNames.DESCRIPTION, PrimaryInputNames.LOCATION, - PrimaryInputNames.INCIDENT_TYPE, - PrimaryInputNames.DATE + PrimaryInputNames.DATE_START, + PrimaryInputNames.DATE_END ], [SearchTypes.OFFICERS]: [ PrimaryInputNames.OFFICER_NAME, PrimaryInputNames.LOCATION, - PrimaryInputNames.BADGE_NUMBER + PrimaryInputNames.BADGE_NUMBER, + PrimaryInputNames.DATE_START, + PrimaryInputNames.DATE_END ] } diff --git a/frontend/models/profile.tsx b/frontend/models/profile.tsx index 323a9a39..30837b60 100644 --- a/frontend/models/profile.tsx +++ b/frontend/models/profile.tsx @@ -73,7 +73,7 @@ export const publicUser = (user: User): UserDataType => ({ firstName: user.firstName || "", lastName: user.lastName || "", email: user.email, - phone: fakePhoneNumber, + phone: user.phoneNumber, active: user.active, role: UserRoles.PUBLIC }) @@ -82,7 +82,7 @@ export const passportUser = (user: User): UserDataType => ({ firstName: user.firstName || "", lastName: user.lastName || "", email: user.email, - phone: fakePhoneNumber, + phone: user.phoneNumber, active: user.active, role: UserRoles.PASSPORT }) @@ -91,7 +91,7 @@ export const contributorUser = (user: User): UserDataType => ({ firstName: user.firstName || "", lastName: user.lastName || "", email: user.email, - phone: fakePhoneNumber, + phone: user.phoneNumber, active: user.active, role: UserRoles.CONTRIBUTOR }) @@ -100,7 +100,7 @@ export const adminUser = (user: User): UserDataType => ({ firstName: user.firstName || "", lastName: user.lastName || "", email: user.email, - phone: fakePhoneNumber, + phone: user.phoneNumber, active: user.active, role: UserRoles.ADMIN }) @@ -109,7 +109,7 @@ export const someUser = (user: User, role: UserRoles): UserDataType => ({ firstName: user.firstName || "", lastName: user.lastName || "", email: user.email, - phone: fakePhoneNumber, + phone: user.phoneNumber, active: user.active, role: role }) @@ -152,6 +152,3 @@ export const profileTypeContent: { [key in UserRoles]: ProfileTypeText } = { content: "" } } - -// not currently part of API data -export const fakePhoneNumber = "9995550123" diff --git a/frontend/models/response.tsx b/frontend/models/response.tsx index 56b7c564..b3fa0453 100644 --- a/frontend/models/response.tsx +++ b/frontend/models/response.tsx @@ -8,7 +8,8 @@ interface EnrollmentErrorText { export enum EnrollmentTypes { VIEWER = "viewer", - PASSPORT = "passport" + PASSPORT = "passport", + CONTRIBUTOR = "contributor" } export const enrollmentMessage: { [key in EnrollmentTypes]: EnrollmentErrorText } = { @@ -21,5 +22,10 @@ export const enrollmentMessage: { [key in EnrollmentTypes]: EnrollmentErrorText statusMessage: "submit your application", returnText: "Return to dashboard", returnPath: AppRoutes.DASHBOARD + }, + [EnrollmentTypes.CONTRIBUTOR]: { + statusMessage: "submit your application", + returnText: "Return to dashboard", + returnPath: AppRoutes.DASHBOARD } } diff --git a/frontend/models/secondary-input.tsx b/frontend/models/secondary-input.tsx new file mode 100644 index 00000000..a10871ed --- /dev/null +++ b/frontend/models/secondary-input.tsx @@ -0,0 +1,19 @@ +export enum SecondaryInputNames { + SOURCE = "source" +} + +const passwordRgx: RegExp = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d\s]).{8,}$/ +const nameRgx: RegExp = new RegExp("^[' -]*[a-z]+[a-z' -]+$", "i") +const anyString: RegExp = new RegExp("[sS]*") +const phoneNumberRgx: RegExp = /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/ +const emailRgx: RegExp = /^[a-z0-9_.-]+@[a-z0-9_.-]+\.[a-z]{2,4}$/i +const urlRgx: RegExp = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/ + +export const secondaryInputValidation = { + [SecondaryInputNames.SOURCE]: { + errorMessage: "A badge number requires 2+ letters", + pattern: anyString, + inputType: "text" + } +} + diff --git a/frontend/pages/contributor/contributor.module.css b/frontend/pages/contributor/contributor.module.css new file mode 100644 index 00000000..39cf7a74 --- /dev/null +++ b/frontend/pages/contributor/contributor.module.css @@ -0,0 +1,21 @@ +.contributorIntro { + width: var(--size384); + margin-bottom: var(--size8); +} + +.contributorForm { + display: flex; + flex-direction: column; + align-items: center; + width: var(--size424); +} + +.contributorForm fieldset { + display: flex; + flex-flow: row wrap; +} + +.submissionConfirmation { + max-width: var(--size424); + overflow: scroll; +} diff --git a/frontend/pages/contributor/index.tsx b/frontend/pages/contributor/index.tsx new file mode 100644 index 00000000..449588d8 --- /dev/null +++ b/frontend/pages/contributor/index.tsx @@ -0,0 +1,89 @@ +import React, { FormEvent, useState } from "react" +import styles from "./contributor.module.css" +import { EnrollmentCallToAction, EnrollmentHeader } from "../../compositions" +import { CallToActionTypes, PrimaryInputNames } from "../../models" +import { + Layout, + PrimaryButton, + PrimaryInput, + ResponseTextArea, + USAStateInput +} from "../../shared-components" +import { requireAuth, useAuth } from "../../helpers" +import { FormProvider, useForm } from "react-hook-form" + +export default requireAuth(function Passport() { + const { contributorForm, contributorIntro } = styles + const { ORGANIZATION_NAME, ORGANIZATION_URL, ORGANIZATION_EMAIL, CITY_TOWN, STREET_ADDRESS, ZIP_CODE } = PrimaryInputNames + + const form = useForm() + const { user } = useAuth() + const userName = [user.firstName, user.lastName].filter(Boolean).join(" ") || "there" + const [loading, setLoading] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [submitSuccess, setSubmitSuccess] = useState(null) + + async function onSubmit(formValues: any) { + setLoading(true) + setSubmitError(null) + const values = { + organizationName: formValues[ORGANIZATION_NAME], + organizationUrl: formValues[ORGANIZATION_URL], + organizationContact: formValues[ORGANIZATION_EMAIL], + cityOrTown: formValues[CITY_TOWN], + streetAddress: formValues[STREET_ADDRESS], + signupReason: formValues[ResponseTextArea.inputName], + zipCode: formValues[ZIP_CODE], + state: formValues[USAStateInput.inputName] + } + // TODO: submit form + // await new Promise((r) => setTimeout(r, 500)) + setLoading(false) + setSubmitSuccess(`Thank you for your submission:\n${JSON.stringify(values, null, 2)}`) + } + + function onError(e: any) { + console.log(e) + } + return ( + +
+ +

+ Hello {userName}, thank you for your continued interest in the National Police + Data Coalition. +
+
+ In order to become a contributor to the Police Data Index, you will need to create or join a contributing organization. Please fill out the form below to get started. +

+ +
+
+ + + + + + + +
+ + {submitSuccess ? ( + + ) : ( + + Submit + + )} + +
+ +
+
+ ) +}) + +// TODO: Update to reflect real form submission flow +function SubmissionConfirmation({ message }: { message: any }) { + return
{message}
+} diff --git a/frontend/pages/contributor/response.tsx b/frontend/pages/contributor/response.tsx new file mode 100644 index 00000000..3f531e1d --- /dev/null +++ b/frontend/pages/contributor/response.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import { PassportApplicationResponse } from "../../compositions/enrollment-response/enrollment-response" + +export default function Response() { + const wrapper = { + width: "60%", + margin: "20vh auto" + } + + return ( +
+ + +
+ ) +} diff --git a/frontend/shared-components/index.tsx b/frontend/shared-components/index.tsx index 22d00f49..cecdf6c0 100644 --- a/frontend/shared-components/index.tsx +++ b/frontend/shared-components/index.tsx @@ -4,6 +4,7 @@ import InfoTooltip from "./info-tooltip/info-tooltip" import Layout from "./layout/layout" import Logo from "./logo/logo" import PrimaryInput from "./primary-input/primary-input" +import SecondaryInput from "./secondary-input/secondary-input" import ResponseTextArea from "./response-textarea/response-textarea" import USAStateInput from "./state-input/state-input" import PrimaryButton from "./primary-button/primary-button" @@ -21,6 +22,7 @@ export { PrimaryButton, LinkButton, PrimaryInput, + SecondaryInput, ResponseTextArea, USAStateInput, ResultsAlert, diff --git a/frontend/shared-components/response-textarea/response-textarea.tsx b/frontend/shared-components/response-textarea/response-textarea.tsx index 9279b16c..4856c4aa 100644 --- a/frontend/shared-components/response-textarea/response-textarea.tsx +++ b/frontend/shared-components/response-textarea/response-textarea.tsx @@ -9,7 +9,7 @@ export default function ResponseTextArea() { formState: { errors } } = useFormContext() const [textareaId, counterId, errorId] = ["responseTextArea", "responseCounter", "responseError"] - const [charMax, charMin] = [500, 150] + const [charMax, charMin] = [500, 20] const inputName = ResponseTextArea.inputName const defaultErrorMessage = `Please provide a response of at least ${charMin} characters` @@ -28,7 +28,7 @@ export default function ResponseTextArea() { return (
- +