Skip to content

Commit

Permalink
🎉 suggested input (#140)
Browse files Browse the repository at this point in the history
  • Loading branch information
casperiv0 authored Nov 10, 2021
2 parents cd2d410 + 0b3f225 commit 9c2d0b5
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 43 deletions.
22 changes: 18 additions & 4 deletions packages/api/src/controllers/leo/SearchController.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller, UseBeforeEach } from "@tsed/common";
import { JsonRequestBody, Post } from "@tsed/schema";
import { NotFound } from "@tsed/exceptions";
import { BodyParams } from "@tsed/platform-params";
import { BodyParams, QueryParams } from "@tsed/platform-params";
import { prisma } from "../../lib/prisma";
import { IsAuth } from "../../middlewares";
import { ActiveOfficer } from "../../middlewares/ActiveOfficer";
Expand Down Expand Up @@ -113,12 +113,15 @@ export class SearchController {
}

@Post("/vehicle")
async searchVehicle(@BodyParams("plateOrVin") plateOrVin: string) {
async searchVehicle(
@QueryParams("includeMany") includeMany: boolean,
@BodyParams("plateOrVin") plateOrVin: string,
) {
if (!plateOrVin || plateOrVin.length < 3) {
return null;
}

const vehicle = await prisma.registeredVehicle.findFirst({
const data = {
where: {
OR: [
{ plate: { startsWith: plateOrVin.toUpperCase() } },
Expand All @@ -130,7 +133,18 @@ export class SearchController {
model: { include: { value: true } },
registrationStatus: true,
},
});
};

if (includeMany) {
const vehicles = await prisma.registeredVehicle.findMany({
where: { plate: { startsWith: plateOrVin.toUpperCase() } },
include: data.include,
});

return vehicles;
}

const vehicle = await prisma.registeredVehicle.findFirst(data);

if (!vehicle) {
throw new NotFound("vehicleNotFound");
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/controllers/record/RecordsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class RecordsController {
},
});

if (!citizen) {
if (!citizen || `${citizen.name} ${citizen.surname}` !== body.get("citizenName")) {
throw new NotFound("citizenNotFound");
}

Expand Down
3 changes: 2 additions & 1 deletion packages/client/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"dateLargerThanNow": "Please provide a date smaller than the current date.",
"divisionNotInDepartment": "This division does not exist in this department.",
"passwordsDoNotMatch": "Passwords do not match",
"currentPasswordIncorrect": "Current password is incorrect"
"currentPasswordIncorrect": "Current password is incorrect",
"citizenNotFound": "That citizen was not found"
}
}
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"react": "^17.0.2",
"react-bootstrap-icons": "^1.6.1",
"react-colorful": "^5.5.0",
"react-cool-onclickoutside": "^1.7.0",
"react-dom": "^17.0.2",
"react-hot-toast": "^2.1.1",
"react-markdown": "^7.1.0",
Expand Down
79 changes: 66 additions & 13 deletions packages/client/src/components/active-bolos/ManageBoloModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { Form, Formik } from "formik";
import { handleValidate } from "lib/handleValidate";
import useFetch from "lib/useFetch";
import { ModalIds } from "types/ModalIds";
import { BoloType } from "types/prisma";
import { BoloType, Citizen, RegisteredVehicle } from "types/prisma";
import { useTranslations } from "use-intl";
import { CREATE_BOLO_SCHEMA } from "@snailycad/schemas";
import { FullBolo, useDispatchState } from "state/dispatchState";
import { Person, ThreeDots } from "react-bootstrap-icons";
import { Person, PersonFill, ThreeDots } from "react-bootstrap-icons";
import { FormRow } from "components/form/FormRow";
import { classNames } from "lib/classNames";
import { InputSuggestions } from "components/form/InputSuggestions";
import { useImageUrl } from "hooks/useImageUrl";

interface Props {
onClose?: () => void;
Expand All @@ -28,6 +30,7 @@ export const ManageBoloModal = ({ onClose, bolo }: Props) => {
const { isOpen, closeModal } = useModal();
const { state, execute } = useFetch();
const { bolos, setBolos } = useDispatchState();
const { makeImageUrl } = useImageUrl();

async function onSubmit(values: typeof INITIAL_VALUES) {
if (bolo) {
Expand Down Expand Up @@ -133,11 +136,29 @@ export const ManageBoloModal = ({ onClose, bolo }: Props) => {
{values.type === BoloType.VEHICLE ? (
<>
<FormField label={"Plate"}>
<Input
id="plate"
onChange={handleChange}
hasError={!!errors.plate}
value={values.plate}
<InputSuggestions
inputProps={{
id: "plate",
onChange: handleChange,
hasError: !!errors.plate,
value: values.plate,
}}
options={{
apiPath: "/search/vehicle?includeMany=true",
method: "POST",
data: { plateOrVin: values.plate },
}}
onSuggestionClick={(suggestion: RegisteredVehicle) => {
setFieldValue("plate", suggestion.plate);
setFieldValue("model", suggestion.model.value.value);
setFieldValue("color", suggestion.color);
}}
Component={({ suggestion }: { suggestion: RegisteredVehicle }) => (
<p>
{suggestion.plate.toUpperCase()} (
{suggestion.model?.value?.value?.toUpperCase() ?? null})
</p>
)}
/>
<Error>{errors.plate}</Error>
</FormField>
Expand Down Expand Up @@ -166,11 +187,43 @@ export const ManageBoloModal = ({ onClose, bolo }: Props) => {

{values.type === BoloType.PERSON ? (
<FormField label={"Name"}>
<Input
id="name"
onChange={handleChange}
hasError={!!errors.name}
value={values.name}
<InputSuggestions
inputProps={{
id: "name",
onChange: handleChange,
hasError: !!errors.name,
value: values.name,
autoComplete: "false",
autoCorrect: "false",
list: "null",
}}
options={{
apiPath: "/search/name?includeMany=true",
method: "POST",
data: { name: values.name },
minLength: 2,
}}
onSuggestionClick={(suggestion: Citizen) => {
setFieldValue("name", `${suggestion.name} ${suggestion.surname}`);
}}
Component={({ suggestion }: { suggestion: Citizen }) => (
<div className="flex items-center">
<div className="mr-2 min-w-[25px]">
{suggestion.imageId ? (
<img
className="rounded-md w-[35px] h-[35px] object-cover"
draggable={false}
src={makeImageUrl("citizens", suggestion.imageId)}
/>
) : (
<PersonFill className="text-gray-500/60 w-[25px] h-[25px]" />
)}
</div>
<p>
{suggestion.name} {suggestion.surname}
</p>
</div>
)}
/>
<Error>{errors.name}</Error>
</FormField>
Expand All @@ -186,7 +239,7 @@ export const ManageBoloModal = ({ onClose, bolo }: Props) => {
<Error>{errors.description}</Error>
</FormField>

<footer className="mt-5 flex justify-end">
<footer className="flex justify-end mt-5">
<Button type="reset" onClick={handleClose} variant="cancel">
{common("cancel")}
</Button>
Expand Down
75 changes: 75 additions & 0 deletions packages/client/src/components/form/InputSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Method } from "axios";
import useFetch from "lib/useFetch";
import * as React from "react";
import useOnclickOutside from "react-cool-onclickoutside";
import { Input } from "./Input";

interface Props {
inputProps?: JSX.IntrinsicElements["input"] & { hasError?: boolean };
onSuggestionClick?: (suggestion: any) => void;
Component: ({ suggestion }: { suggestion: any }) => JSX.Element;
options: { apiPath: string; method: Method; data: any; minLength?: number };
}

export const InputSuggestions = ({ Component, onSuggestionClick, options, inputProps }: Props) => {
const [isOpen, setOpen] = React.useState(false);
const [suggestions, setSuggestions] = React.useState<any[]>([]);
const { execute } = useFetch();
const ref = useOnclickOutside(() => setOpen(false));

async function onSearch(e: React.ChangeEvent<HTMLInputElement>) {
const target = e.target as HTMLInputElement;
const value = target.value;

if (value.length < (options.minLength ?? 3)) {
setOpen(false);
return;
}

const { json } = await execute(options.apiPath, {
...options,
});

if (json && Array.isArray(json)) {
setSuggestions(json);
setOpen(true);
}
}

function handleSuggestionClick(suggestion: any) {
onSuggestionClick?.(suggestion);
setOpen(false);
}

async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
inputProps?.onChange?.(e);
// todo: debounce
onSearch(e);
}

return (
<div ref={ref} className="relative w-full">
<Input {...(inputProps as any)} onFocus={() => setOpen(true)} onChange={handleChange} />

{isOpen && suggestions.length > 0 ? (
<div className="absolute z-50 w-full p-2 overflow-auto bg-white rounded-md shadow-md top-11 dark:bg-dark-bright max-h-60">
<ul>
{suggestions.map((suggestion) => (
<li
className="p-1.5 px-2 transition-colors rounded-md cursor-pointer hover:bg-gray-200 dark:hover:bg-dark-bg"
key={suggestion.id}
onClick={() => handleSuggestionClick(suggestion)}
>
{Component ? (
<Component suggestion={suggestion} />
) : (
<p>{JSON.stringify(suggestion)}</p>
)}
</li>
))}
</ul>
</div>
) : null}
</div>
);
};
60 changes: 45 additions & 15 deletions packages/client/src/components/leo/modals/CreateTicketModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import useFetch from "lib/useFetch";
import { ModalIds } from "types/ModalIds";
import { useTranslations } from "use-intl";
import { Textarea } from "components/form/Textarea";
import { useCitizen } from "context/CitizenContext";
import { RecordType } from "types/prisma";
import { Citizen, RecordType } from "types/prisma";
import { InputSuggestions } from "components/form/InputSuggestions";
import { PersonFill } from "react-bootstrap-icons";
import { useImageUrl } from "hooks/useImageUrl";

export const CreateTicketModal = ({ type }: { type: RecordType }) => {
const { isOpen, closeModal, getPayload } = useModal();
Expand All @@ -39,7 +41,7 @@ export const CreateTicketModal = ({ type }: { type: RecordType }) => {

const { state, execute } = useFetch();
const { penalCode } = useValues();
const { citizens } = useCitizen();
const { makeImageUrl } = useImageUrl();

async function onSubmit(values: typeof INITIAL_VALUES) {
const { json } = await execute("/records", {
Expand All @@ -56,10 +58,12 @@ export const CreateTicketModal = ({ type }: { type: RecordType }) => {
}
}

const payload = getPayload<{ citizenId: string; citizenName: string }>(data[type].id);
const validate = handleValidate(CREATE_TICKET_SCHEMA);
const INITIAL_VALUES = {
type,
citizenId: getPayload<{ citizenId: string }>(data[type].id)?.citizenId ?? "",
citizenId: payload?.citizenId ?? "",
citizenName: payload?.citizenName ?? "",
violations: [] as SelectValue[],
postal: "",
notes: "",
Expand All @@ -73,18 +77,44 @@ export const CreateTicketModal = ({ type }: { type: RecordType }) => {
className="w-[600px]"
>
<Formik validate={validate} initialValues={INITIAL_VALUES} onSubmit={onSubmit}>
{({ handleChange, errors, values, isValid }) => (
{({ handleChange, setFieldValue, errors, values, isValid }) => (
<Form>
<FormField label={t("citizen")}>
<Select
value={values.citizenId}
hasError={!!errors.citizenId}
name="citizenId"
onChange={handleChange}
values={citizens.map((v) => ({
label: `${v.name} ${v.surname}`,
value: v.id,
}))}
<InputSuggestions
inputProps={{
value: values.citizenName,
hasError: !!errors.citizenName,
name: "citizenName",
onChange: handleChange,
}}
onSuggestionClick={(suggestion) => {
setFieldValue("citizenId", suggestion.id);
setFieldValue("citizenName", `${suggestion.name} ${suggestion.surname}`);
}}
options={{
apiPath: "/search/name",
data: { name: values.citizenName },
method: "POST",
minLength: 2,
}}
Component={({ suggestion }: { suggestion: Citizen }) => (
<div className="flex items-center">
<div className="mr-2 min-w-[25px]">
{suggestion.imageId ? (
<img
className="rounded-md w-[35px] h-[35px] object-cover"
draggable={false}
src={makeImageUrl("citizens", suggestion.imageId)}
/>
) : (
<PersonFill className="text-gray-500/60 w-[25px] h-[25px]" />
)}
</div>
<p>
{suggestion.name} {suggestion.surname}
</p>
</div>
)}
/>
<Error>{errors.citizenId}</Error>
</FormField>
Expand Down Expand Up @@ -124,7 +154,7 @@ export const CreateTicketModal = ({ type }: { type: RecordType }) => {
<Error>{errors.notes}</Error>
</FormField>

<footer className="mt-5 flex justify-end">
<footer className="flex justify-end mt-5">
<Button type="reset" onClick={() => closeModal(data[type].id)} variant="cancel">
{common("cancel")}
</Button>
Expand Down
Loading

0 comments on commit 9c2d0b5

Please sign in to comment.