diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3e76a0ac7..4a3c26eb7 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,7 +1,6 @@ name: pull-request on: pull_request: - branches: [main, staging, release/**] jobs: test: runs-on: ubuntu-latest diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 9d6e54a2f..948b14281 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -27,28 +27,34 @@ type EnvironmentName = (typeof ENVIRONMENT_NAMES)[number]; type Environment = { apiBaseUrl: string; userServiceUrl: string; + entityServiceUrl: string; }; const ENVIRONMENTS: { [Property in EnvironmentName]: Environment } = { local: { apiBaseUrl: "http://localhost:8080", - userServiceUrl: "http://localhost:4010" + userServiceUrl: "http://localhost:4010", + entityServiceUrl: "http://localhost:4050" }, dev: { apiBaseUrl: "https://api-dev.terramatch.org", - userServiceUrl: "https://api-dev.terramatch.org" + userServiceUrl: "https://api-dev.terramatch.org", + entityServiceUrl: "https://api-dev.terramatch.org" }, test: { apiBaseUrl: "https://api-test.terramatch.org", - userServiceUrl: "https://api-test.terramatch.org" + userServiceUrl: "https://api-test.terramatch.org", + entityServiceUrl: "https://api-test.terramatch.org" }, staging: { apiBaseUrl: "https://api-staging.terramatch.org", - userServiceUrl: "https://api-staging.terramatch.org" + userServiceUrl: "https://api-staging.terramatch.org", + entityServiceUrl: "https://api-staging.terramatch.org" }, prod: { apiBaseUrl: "https://api.terramatch.org", - userServiceUrl: "https://api.terramatch.org" + userServiceUrl: "https://api.terramatch.org", + entityServiceUrl: "https://api.terramatch.org" } }; @@ -60,13 +66,15 @@ if (!ENVIRONMENT_NAMES.includes(declaredEnv as EnvironmentName)) { const DEFAULTS = ENVIRONMENTS[declaredEnv]; const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULTS.apiBaseUrl; const userServiceUrl = process.env.NEXT_PUBLIC_USER_SERVICE_URL ?? DEFAULTS.userServiceUrl; +const entityServiceUrl = process.env.NEXT_PUBLIC_ENTITY_SERVICE_URL ?? DEFAULTS.entityServiceUrl; // The services defined in the v3 Node BE codebase. Although the URL path for APIs in the v3 space // are namespaced by feature set rather than service (a service may contain multiple namespaces), we // isolate the generated API integration by service to make it easier for a developer to find where // the associated BE code is for a given FE API integration. const SERVICES = { - "user-service": userServiceUrl + "user-service": userServiceUrl, + "entity-service": entityServiceUrl }; const config: Record = { diff --git a/package.json b/package.json index a61cc42ed..d184e19fb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build-storybook": "storybook build", "generate:api": "openapi-codegen gen api", "generate:userService": "openapi-codegen gen userService", - "generate:services": "npm run generate:userService", + "generate:entityService": "openapi-codegen gen entityService", + "generate:services": "yarn generate:userService && yarn generate:entityService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/admin/components/EntityEdit/EntityEdit.tsx b/src/admin/components/EntityEdit/EntityEdit.tsx index a81045de0..81ef49f10 100644 --- a/src/admin/components/EntityEdit/EntityEdit.tsx +++ b/src/admin/components/EntityEdit/EntityEdit.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import modules from "@/admin/modules"; import WizardForm from "@/components/extensive/WizardForm"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import EntityProvider from "@/context/entity.provider"; import FrameworkProvider, { Framework } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, @@ -73,31 +74,33 @@ export const EntityEdit = () => {
- navigate("..")} - onChange={data => - updateEntity({ - pathParams: { uuid: entityUUID, entity: entityName }, - body: { answers: normalizedFormData(data, formSteps!) } - }) - } - formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} - onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} - defaultValues={defaultValues} - title={title} - tabOptions={{ - markDone: true, - disableFutureTabs: true - }} - summaryOptions={{ - title: "Review Details", - downloadButtonText: "Download" - }} - roundedCorners - hideSaveAndCloseButton - /> + + navigate("..")} + onChange={data => + updateEntity({ + pathParams: { uuid: entityUUID, entity: entityName }, + body: { answers: normalizedFormData(data, formSteps!) } + }) + } + formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} + onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} + defaultValues={defaultValues} + title={title} + tabOptions={{ + markDone: true, + disableFutureTabs: true + }} + summaryOptions={{ + title: "Review Details", + downloadButtonText: "Download" + }} + roundedCorners + hideSaveAndCloseButton + /> +
diff --git a/src/assets/icons/add-button.svg b/src/assets/icons/add-button.svg new file mode 100644 index 000000000..b2d5c5208 --- /dev/null +++ b/src/assets/icons/add-button.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/edit-ta.svg b/src/assets/icons/edit-ta.svg new file mode 100644 index 000000000..85d3d0d07 --- /dev/null +++ b/src/assets/icons/edit-ta.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/new-tag-tree-species.svg b/src/assets/icons/new-tag-tree-species.svg new file mode 100644 index 000000000..e79ab3b38 --- /dev/null +++ b/src/assets/icons/new-tag-tree-species.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/non-scientific name.svg b/src/assets/icons/non-scientific name.svg new file mode 100644 index 000000000..7bb70d7f4 --- /dev/null +++ b/src/assets/icons/non-scientific name.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/trash-ta.svg b/src/assets/icons/trash-ta.svg new file mode 100644 index 000000000..551bd1526 --- /dev/null +++ b/src/assets/icons/trash-ta.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx index 57c6b16dc..827ed397d 100644 --- a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx +++ b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx @@ -1,9 +1,11 @@ import { Popover, Transition } from "@headlessui/react"; import { useT } from "@transifex/react"; +import classNames from "classnames"; import { ChangeEvent, forwardRef, Fragment, Ref, useState } from "react"; import { Else, If, Then } from "react-if"; import { useDebounce } from "@/hooks/useDebounce"; +import { useValueChanged } from "@/hooks/useValueChanged"; import Text from "../../Text/Text"; import Input, { InputProps } from "../Input/Input"; @@ -11,13 +13,19 @@ import Input, { InputProps } from "../Input/Input"; export interface AutoCompleteInputProps extends InputProps { onSearch: (query: string) => Promise; disableAutoComplete?: boolean; + classNameMenu?: string; } +const SEARCH_RESET = { list: [], query: "" }; + //TODO: Bugfix: Users can enter space in this input const AutoCompleteInput = forwardRef( - ({ onSearch, disableAutoComplete, ...inputProps }: AutoCompleteInputProps, ref?: Ref) => { + ( + { onSearch, disableAutoComplete, classNameMenu, ...inputProps }: AutoCompleteInputProps, + ref?: Ref + ) => { const t = useT(); - const [list, setList] = useState([]); + const [searchResult, setSearchResult] = useState<{ list: string[]; query: string }>(SEARCH_RESET); const [loading, setLoading] = useState(false); const onSelect = (item: string) => { @@ -27,35 +35,34 @@ const AutoCompleteInput = forwardRef( inputProps.onChange?.({ target: { name: inputProps.name, value: item } } as ChangeEvent); } - setList([]); + // Avoid showing the search result list unless the name changes again. + setSearchResult({ list: [], query: item }); }; const search = useDebounce(async (query: string) => { + if (query === searchResult.query) return; + setLoading(true); - onSearch(query) - .then(resp => { - setList(resp); - setLoading(false); - }) - .catch(() => { - setList([]); - setLoading(false); - }); + try { + setSearchResult({ list: await onSearch(query), query }); + setLoading(false); + } catch { + setSearchResult(SEARCH_RESET); + setLoading(false); + } }); + useValueChanged(inputProps.value, () => search(String(inputProps.value ?? ""))); + return ( - !disableAutoComplete && search(e.currentTarget.value)} - /> + 0 || !!loading} + show={searchResult.list.length > 0 || !!loading} enter="transition duration-100 ease-out" enterFrom="transform scale-95 opacity-0" enterTo="transform scale-100 opacity-100" @@ -63,7 +70,10 @@ const AutoCompleteInput = forwardRef( leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" > - + @@ -71,7 +81,7 @@ const AutoCompleteInput = forwardRef( - {list.map(item => ( + {searchResult.list.map(item => ( , HTMLInputElement>, "type" | "form"> { name: string; - variant?: "secondary" | "default" | "login" | "signup" | "monitored"; + variant?: "secondary" | "default" | "login" | "signup" | "monitored" | "treePlanted"; formHook?: UseFormReturn; clearable?: boolean; iconButtonProps?: IconButtonProps; @@ -112,6 +112,11 @@ const Input = forwardRef( true, "pl-4": inputProps.type === "number", "border-neutral-300": !error + }, + treePlanted: { + "py-[7.5px] py-1.5 !w-[100px] text-center border border-blueCustom-700 rounded hover:border-primary hover:shadow-blue-border opacity-60 outline-none text-14-light !font-primary": + true, + "text-center": inputProps.type === "number" } }; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index ca047e0cd..01ef90959 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -1,19 +1,27 @@ import { useT } from "@transifex/react"; -import { remove } from "lodash"; -import { Fragment, KeyboardEvent, useCallback, useId, useRef } from "react"; +import classNames from "classnames"; +import { isEmpty, remove } from "lodash"; +import { Fragment, KeyboardEvent, useCallback, useId, useMemo, useRef, useState } from "react"; import { FieldError, FieldErrors } from "react-hook-form"; -import { When } from "react-if"; +import { Else, If, Then, When } from "react-if"; import { v4 as uuidv4 } from "uuid"; -import { IconNames } from "@/components/extensive/Icon/Icon"; +import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; +import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { EstablishmentEntityType, useEstablishmentTrees } from "@/connections/EstablishmentTrees"; +import { useEntityContext } from "@/context/entity.provider"; +import { useModalContext } from "@/context/modal.provider"; import { useDebounce } from "@/hooks/useDebounce"; +import { isReportModelName } from "@/types/common"; import { updateArrayState } from "@/utils/array"; import Button from "../../Button/Button"; import ErrorMessage from "../../ErrorMessage/ErrorMessage"; import IconButton from "../../IconButton/IconButton"; import Text from "../../Text/Text"; +import AutoCompleteInput from "../AutoCompleteInput/AutoCompleteInput"; import Input from "../Input/Input"; import InputWrapper, { InputWrapperProps } from "../InputElements/InputWrapper"; @@ -21,7 +29,6 @@ export interface TreeSpeciesInputProps extends Omit title: string; buttonCaptionSuffix: string; withNumbers?: boolean; - withTreeSearch?: boolean; value: TreeSpeciesValue[]; onChange: (value: any[]) => void; clearErrors: () => void; @@ -31,15 +38,84 @@ export interface TreeSpeciesInputProps extends Omit error?: FieldErrors[]; } -export type TreeSpeciesValue = { uuid?: string; name?: string; amount?: number }; +export type TreeSpeciesValue = { uuid?: string; name?: string; taxon_id?: string; amount?: number }; + +const NonScientificConfirmationModal = ({ onConfirm }: { onConfirm: () => void }) => { + const t = useT(); + const { closeModal } = useModalContext(); + + return ( +
+
+ + + {t("Your input is a not a scientific name")} + +
+
+
+
+ + {t("You can add this species, but it will be pending review from Admin.")} + +
+
+
+ + +
+
+
+ ); +}; const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const id = useId(); const t = useT(); const lastInputRef = useRef(null); + const autoCompleteRef = useRef(null); + + const [valueAutoComplete, setValueAutoComplete] = useState(""); + const [searchResult, setSearchResult] = useState(); + const [editIndex, setEditIndex] = useState(null); + const [deleteIndex, setDeleteIndex] = useState(null); + const [editValue, setEditValue] = useState(null); + const refPlanted = useRef(null); + const refTreeSpecies = useRef(null); + const { openModal } = useModalContext(); + + const { autocompleteSearch, findTaxonId } = useAutocompleteSearch(); const { onChange, value, clearErrors, collection } = props; + const { entityUuid, entityName } = useEntityContext(); + const isEntity = entityName != null && entityUuid != null; + const isReport = isEntity && isReportModelName(entityName); + const handleBaseEntityTrees = isReport || (isEntity && ["sites", "nurseries"].includes(entityName)); + + const entity = (handleBaseEntityTrees ? entityName : undefined) as EstablishmentEntityType; + const uuid = handleBaseEntityTrees ? entityUuid : undefined; + const [, { establishmentTrees, previousPlantingCounts }] = useEstablishmentTrees({ entity, uuid }); + + const totalWithPrevious = useMemo( + () => + props.value.reduce( + (total, { name, amount }) => total + (amount ?? 0) + (previousPlantingCounts?.[name ?? ""] ?? 0), + 0 + ), + [previousPlantingCounts, props.value] + ); + const handleCreate = useDebounce( useCallback( (treeValue: TreeSpeciesValue) => { @@ -73,9 +149,50 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const addValue = (e: React.MouseEvent | KeyboardEvent) => { e.preventDefault(); - if (!props.error) { - handleCreate?.({ uuid: uuidv4(), name: undefined, amount: undefined }); - lastInputRef.current && lastInputRef.current.focus(); + if (props.error) return; + + const taxonId = findTaxonId(valueAutoComplete); + + const doAdd = () => { + handleCreate?.({ + uuid: uuidv4(), + name: valueAutoComplete, + taxon_id: taxonId, + amount: props.withNumbers ? 0 : undefined + }); + + setValueAutoComplete(""); + lastInputRef.current?.focus(); + }; + + if (!isEmpty(searchResult) && taxonId == null) { + // In this case the use had valid values to choose from, but decided to add a value that isn't + // on the list, so they haven't been shown the warning yet. + openModal(ModalId.ERROR_MODAL, ); + } else { + doAdd(); + } + }; + + const updateValue = () => { + const taxonId = findTaxonId(valueAutoComplete); + + const doUpdate = () => { + setEditIndex(null); + + handleUpdate({ + ...editValue, + name: valueAutoComplete, + taxon_id: findTaxonId(valueAutoComplete) + }); + + setValueAutoComplete(""); + }; + + if (!isEmpty(searchResult) && taxonId == null) { + openModal(ModalId.ERROR_MODAL, ); + } else { + doUpdate(); } }; @@ -88,78 +205,239 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { return (
-
- - {props.title} ({props.value.length}) + {handleBaseEntityTrees && ( +
+ + {t( + "If you would like to add a species not included on the original Restoration Project, it will be flagged to the admin as new information pending review." + )} +
+ )} +
+ + {t("Scientific Name:")} - - - {t(`Total Count: ({number})`, { number: props.value.reduce((total, v) => total + (v.amount || 0), 0) })} +
+
+ setValueAutoComplete(e.target.value)} + onSearch={async search => { + const result = await autocompleteSearch(search); + setSearchResult(result); + return result; + }} + /> + 0}> + + +
+ + + + + + + + + +
+
+ +
+ + {t("No matches available")} + +
+ + + {t("You can add this species, but it will be pending review from Admin.")} + +
+
+
+
+
+ + {props.title} + + + {props.value.length} +
+
+ + {isReport ? t("SPECIES PLANTED:") : t("TREES TO BE PLANTED:")} + + + {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0).toLocaleString() : "0"} + +
+ +
+ + {t("TOTAL PLANTED TO DATE:")} + + + {totalWithPrevious.toLocaleString()} + +
- ( -
- handleUpdate({ ...value, name: e.target.value })} - placeholder={t("Species Name")} - error={props.error?.[index]?.name ? ({} as FieldError) : undefined} - onKeyDownCapture={onKeyDownCapture} - containerClassName="flex-1" - /> - +
+ +
+ + {t(`Are you sure you want to delete “${value.name}”?`)} + +
+ + +
+
+
+ +
+ + + {t("Editing: {name}", { name: value.name })} + +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ + {t(value.name)} + +
+
+
handleUpdate({ ...value, amount: +e.target.value })} + onChange={e => (props.withNumbers ? handleUpdate({ ...value, amount: +e.target.value }) : {})} onKeyDownCapture={onKeyDownCapture} - containerClassName="flex-3" + containerClassName="" /> +
+ + + {(previousPlantingCounts?.[value.name ?? ""] ?? 0).toLocaleString()} + - handleDelete(props.value?.[index]?.uuid)} - /> +
+ { + setValueAutoComplete(value.name ?? ""); + setEditIndex(value.uuid ?? null); + setEditValue(value); + autoCompleteRef.current?.focus(); + }} + /> + setDeleteIndex(value.uuid ?? null)} + /> +
)} /> -
); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot index 84810eee2..9810e81fc 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot +++ b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot @@ -9,7 +9,7 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` data-testid="txt" htmlFor=":r23:" > - Tree Species Grown * + ADD TREE SPECIES *

- ( - 1 - ) + Scientific Name:

-
-
- +
+
+ +
+
-
+
+
- Add Another undefined - - +
+
+
+
+
+

+ Test +

+
+
+
+
+
+ +
+
+
+
+ + +
+
+
`; @@ -104,9 +227,9 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`]

- ( - 1 - ) + Scientific Name:

-

- Total Count: (23) -

-
-
- -
-
-
-
- +
+
+ +
+
- +

+

+ 1 +

+
+
+

+ TREES TO BE PLANTED: +

+

+ 23 +

+
+
+
+
+
+
+
+
+
+

+ Test +

+
+
+
+
+
+ +
+
+
+
+ + +
+
+
`; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts new file mode 100644 index 000000000..a49376af8 --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts @@ -0,0 +1,71 @@ +import { isEmpty } from "lodash"; +import { useCallback, useMemo } from "react"; + +import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { resolveUrl } from "@/generated/v3/utils"; + +type ScientificName = { taxonId: string; scientificName: string }; + +async function searchRequest(search: string) { + const headers: HeadersInit = { "Content-Type": "application/json" }; + const accessToken = typeof window !== "undefined" && getAccessToken(); + if (accessToken != null) headers.Authorization = `Bearer ${accessToken}`; + + const url = resolveUrl(`/trees/v3/scientific-names`, { search }); + const response = await fetch(url, { headers }); + if (!response.ok) { + let error; + try { + error = { + statusCode: response.status, + ...(await response.json()) + }; + } catch (e) { + error = { statusCode: -1 }; + } + + throw error; + } + + const payload = await response.json(); + const data = payload.data as { id: string; attributes: { scientificName: string } }[]; + return data.map(({ id, attributes: { scientificName } }) => ({ taxonId: id, scientificName } as ScientificName)); +} + +/** + * This accesses the v3 tree species search endpoint, but skips the Connection system and the + * top level redux caching. Instead, it provides a simple method to issue a search and will return + * the locally cached result if the same search is issued multiple times (as can happen if a user + * types some characters, then backspaces a couple to type new ones). + */ +export function useAutocompleteSearch() { + const cache = useMemo(() => new Map(), []); + + const autocompleteSearch = useCallback( + async (search: string): Promise => { + const mapNames = (names: ScientificName[]) => names.map(({ scientificName }) => scientificName); + + if (isEmpty(search)) return []; + if (cache.has(search)) return mapNames(cache.get(search) as ScientificName[]); + + const names = await searchRequest(search); + cache.set(search, names); + return mapNames(names); + }, + [cache] + ); + + const findTaxonId = useCallback( + (name: string) => { + for (const names of cache.values()) { + const found = names.find(({ scientificName }) => scientificName === name); + if (found != null) return found.taxonId; + } + + return undefined; + }, + [cache] + ); + + return { autocompleteSearch, findTaxonId }; +} diff --git a/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot b/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot index d9cc8e856..619833a88 100644 --- a/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot +++ b/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot @@ -7,7 +7,7 @@ exports[`Storyshots Components/Elements/Inputs/TextArea Default 1`] = ` @@ -19,11 +19,11 @@ exports[`Storyshots Components/Elements/Inputs/TextArea Default 1`] = ` } } data-testid="txt" - id=":r28:-description" + id=":r2d:-description" />