From e22b5eda920a2d0c5d6a8c335308f0d392697d70 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 5 Dec 2024 15:50:02 -0800 Subject: [PATCH 01/15] [TM-1402] Wire up the autocomplete search API. --- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 15 +-- .../TreeSpeciesInput/useAutocompleteSearch.ts | 52 +++++++++++ src/constants/environment.ts | 91 +++++++++---------- src/generated/v3/utils.ts | 5 +- 4 files changed, 103 insertions(+), 60 deletions(-) create mode 100644 src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index a1c767f73..31c6bb774 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -6,6 +6,7 @@ import { FieldError, FieldErrors } from "react-hook-form"; import { Else, If, Then, When } from "react-if"; import { v4 as uuidv4 } from "uuid"; +import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { useDebounce } from "@/hooks/useDebounce"; @@ -48,6 +49,8 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const refTotal = useRef(null); const refTreeSpecies = useRef(null); + const autocompleteSearch = useAutocompleteSearch(); + const { onChange, value, clearErrors, collection } = props; const handleCreate = useDebounce( @@ -131,18 +134,10 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { value={valueAutoComplete} onChange={e => setValueAutoComplete(e.target.value)} onSearch={(query: string) => { - console.log("Query", query); if (query === "non-scientific name") return Promise.resolve([]); - return Promise.resolve([ - "Amadea diffusa", - "Amadea occidentalis", - "Amadea puberulenta", - "Amadea lorem ipsum", - "Amadea lorem ipsum" - ]); + return autocompleteSearch(query); }} onSelected={item => { - console.log(item); setValueAutoComplete(item); }} /> @@ -186,7 +181,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- {t("You can this add, but it will be pending review from Admin.")} + {t("You can add this species, but it will be pending review from Admin.")}
diff --git a/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts new file mode 100644 index 000000000..5f0ff2f5a --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts @@ -0,0 +1,52 @@ +import { isEmpty } from "lodash"; +import { useCallback, useMemo } from "react"; + +import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { resolveUrl } from "@/generated/v3/utils"; + +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 { attributes: { scientificName: string } }[]; + return data.map(({ attributes }) => attributes.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(), []); + return useCallback( + async (search: string) => { + if (isEmpty(search)) return []; + if (cache.has(search)) return cache.get(search); + + const names = await searchRequest(search); + cache.set(search, names); + return names; + }, + [cache] + ); +} diff --git a/src/constants/environment.ts b/src/constants/environment.ts index 3e6dcdb74..c0e55498d 100644 --- a/src/constants/environment.ts +++ b/src/constants/environment.ts @@ -3,10 +3,17 @@ import Log from "@/utils/log"; const ENVIRONMENT_NAMES = ["local", "dev", "test", "staging", "prod"] as const; type EnvironmentName = (typeof ENVIRONMENT_NAMES)[number]; -type Environment = { +const SERVICES = ["apiBaseUrl", "userServiceUrl", "jobServiceUrl", "entityServiceUrl"] as const; +type Service = (typeof SERVICES)[number]; + +type ServicesDefinition = { apiBaseUrl: string; userServiceUrl: string; jobServiceUrl: string; + entityServiceUrl: string; +}; + +type Environment = ServicesDefinition & { mapboxToken: string; geoserverUrl: string; geoserverWorkspace: string; @@ -19,56 +26,43 @@ const GLOBAL_GEOSERVER_URL = "https://geoserver-prod.wri-restoration-marketplace const GLOBAL_SENTRY_DSN = "https://ab2bb67320b91a124ca3c42460b0e005@o4507018550181888.ingest.us.sentry.io/4507018664869888"; +const GATEWAYS = { + dev: "https://api-dev.terramatch.org", + test: "https://api-test.terramatch.org", + staging: "https://api-staging.terramatch.org", + prod: "https://api.terramatch.org" +}; + +const LOCAL_SERVICE_URLS = { + apiBaseUrl: "http://localhost:8080", + userServiceUrl: "http://localhost:4010", + jobServiceUrl: "http://localhost:4020", + entityServiceUrl: "http://localhost:4050" +}; + +const defaultServiceUrl = (env: EnvironmentName, service: Service) => + env === "local" ? LOCAL_SERVICE_URLS[service] : GATEWAYS[env]; + +const defaultGeoserverWorkspace = (env: EnvironmentName) => + env === "test" ? "wri_test" : env === "prod" ? "wri_prod" : "wri_staging"; + // This is structured so that each environment can be targeted by a NextJS build with a single // NEXT_PUBLIC_TARGET_ENV variable, but each value can be overridden if desired with an associated // value. -const ENVIRONMENTS: { [Property in EnvironmentName]: Environment } = { +const buildDefaults = (env: EnvironmentName): Environment => ({ + ...(SERVICES.reduce( + (serviceUrls, service) => ({ + ...serviceUrls, + [service]: defaultServiceUrl(env, service) + }), + {} + ) as ServicesDefinition), + mapboxToken: GLOBAL_MAPBOX_TOKEN, + geoserverUrl: GLOBAL_GEOSERVER_URL, + geoserverWorkspace: defaultGeoserverWorkspace(env), // Local omits the sentry DSN - local: { - apiBaseUrl: "http://localhost:8080", - userServiceUrl: "http://localhost:4010", - jobServiceUrl: "http://localhost:4020", - mapboxToken: GLOBAL_MAPBOX_TOKEN, - geoserverUrl: GLOBAL_GEOSERVER_URL, - geoserverWorkspace: "wri_staging" - }, - dev: { - apiBaseUrl: "https://api-dev.terramatch.org", - userServiceUrl: "https://api-dev.terramatch.org", - jobServiceUrl: "https://api-dev.terramatch.org", - mapboxToken: GLOBAL_MAPBOX_TOKEN, - geoserverUrl: GLOBAL_GEOSERVER_URL, - geoserverWorkspace: "wri_staging", - sentryDsn: GLOBAL_SENTRY_DSN - }, - test: { - apiBaseUrl: "https://api-test.terramatch.org", - userServiceUrl: "https://api-test.terramatch.org", - jobServiceUrl: "https://api-test.terramatch.org", - mapboxToken: GLOBAL_MAPBOX_TOKEN, - geoserverUrl: GLOBAL_GEOSERVER_URL, - geoserverWorkspace: "wri_test", - sentryDsn: GLOBAL_SENTRY_DSN - }, - staging: { - apiBaseUrl: "https://api-staging.terramatch.org", - userServiceUrl: "https://api-staging.terramatch.org", - jobServiceUrl: "https://api-staging.terramatch.org", - mapboxToken: GLOBAL_MAPBOX_TOKEN, - geoserverUrl: GLOBAL_GEOSERVER_URL, - geoserverWorkspace: "wri_staging", - sentryDsn: GLOBAL_SENTRY_DSN - }, - prod: { - apiBaseUrl: "https://api.terramatch.org", - userServiceUrl: "https://api.terramatch.org", - jobServiceUrl: "https://api.terramatch.org", - mapboxToken: GLOBAL_MAPBOX_TOKEN, - geoserverUrl: GLOBAL_GEOSERVER_URL, - geoserverWorkspace: "wri_prod", - sentryDsn: GLOBAL_SENTRY_DSN - } -}; + sentryDsn: env === "local" ? undefined : GLOBAL_SENTRY_DSN +}); let declaredEnv = process.env.NEXT_PUBLIC_TARGET_ENV ?? "local"; if (!ENVIRONMENT_NAMES.includes(declaredEnv as EnvironmentName)) { @@ -78,10 +72,11 @@ if (!ENVIRONMENT_NAMES.includes(declaredEnv as EnvironmentName)) { Log.info("Booting up with target environment", { declaredEnv }); } -const DEFAULTS = ENVIRONMENTS[declaredEnv as EnvironmentName]; +const DEFAULTS = buildDefaults(declaredEnv as EnvironmentName); export const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULTS.apiBaseUrl; export const userServiceUrl = process.env.NEXT_PUBLIC_USER_SERVICE_URL ?? DEFAULTS.userServiceUrl; export const jobServiceUrl = process.env.NEXT_PUBLIC_JOB_SERVICE_URL ?? DEFAULTS.jobServiceUrl; +export const entityServiceUrl = process.env.NEXT_PUBLIC_ENTITY_SERVICE_URL ?? DEFAULTS.entityServiceUrl; export const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? DEFAULTS.mapboxToken; export const geoserverUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL ?? DEFAULTS.geoserverUrl; export const geoserverWorkspace = process.env.NEXT_PUBLIC_GEOSERVER_WORKSPACE ?? DEFAULTS.geoserverWorkspace; diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 4faf790bc..29a69385c 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,7 +1,7 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; import { selectLogin } from "@/connections/Login"; -import { jobServiceUrl, userServiceUrl } from "@/constants/environment"; +import { entityServiceUrl, jobServiceUrl, userServiceUrl } from "@/constants/environment"; export type ErrorWrapper = TError | { statusCode: -1; message: string }; @@ -16,7 +16,8 @@ type SelectorOptions = { const V3_NAMESPACES: Record = { auth: userServiceUrl, users: userServiceUrl, - jobs: jobServiceUrl + jobs: jobServiceUrl, + trees: entityServiceUrl } as const; const getBaseUrl = (url: string) => { From f4d53dc997bd9a06869fff00777f2483b69f6405 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 6 Dec 2024 12:19:55 -0800 Subject: [PATCH 02/15] [TM-1402] Remove stray console log. --- .../project/[uuid]/reporting-task/[reportingTaskUUID].page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/project/[uuid]/reporting-task/[reportingTaskUUID].page.tsx b/src/pages/project/[uuid]/reporting-task/[reportingTaskUUID].page.tsx index bcde2efe4..d7394c06a 100644 --- a/src/pages/project/[uuid]/reporting-task/[reportingTaskUUID].page.tsx +++ b/src/pages/project/[uuid]/reporting-task/[reportingTaskUUID].page.tsx @@ -187,7 +187,6 @@ const ReportingTaskPage = () => { enableSorting: false, cell: props => { const record = props.row.original as any; - console.log(record); const [isEnabled, setIsEnabled] = useState(true); const { index } = props.row; const { status, type, completion, uuid } = record; From 7fecc40e4ec23a47a27716d97206a3eba1c9e817 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 9 Dec 2024 17:21:31 -0800 Subject: [PATCH 03/15] [TM-1402] Tune up the autocomplete search. --- .../AutoCompleteInput/AutoCompleteInput.tsx | 5 +- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 99 +++++++++++++++---- .../TreeSpeciesInput/useAutocompleteSearch.ts | 33 +++++-- tsconfig.json | 2 +- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx index 80fa54ab2..a412cd21f 100644 --- a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx +++ b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx @@ -13,13 +13,12 @@ export interface AutoCompleteInputProps extends InputProps { onSearch: (query: string) => Promise; disableAutoComplete?: boolean; classNameMenu?: string; - onSelected?: (item: string) => void; } //TODO: Bugfix: Users can enter space in this input const AutoCompleteInput = forwardRef( ( - { onSearch, disableAutoComplete, classNameMenu, onSelected, ...inputProps }: AutoCompleteInputProps, + { onSearch, disableAutoComplete, classNameMenu, ...inputProps }: AutoCompleteInputProps, ref?: Ref ) => { const t = useT(); @@ -33,8 +32,6 @@ const AutoCompleteInput = forwardRef( inputProps.onChange?.({ target: { name: inputProps.name, value: item } } as ChangeEvent); } - onSelected?.(item); - setList([]); }; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 31c6bb774..d2984cec9 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -1,6 +1,6 @@ import { useT } from "@transifex/react"; import classNames from "classnames"; -import { remove } from "lodash"; +import { isEmpty, remove } from "lodash"; import { Fragment, KeyboardEvent, useCallback, useId, useRef, useState } from "react"; import { FieldError, FieldErrors } from "react-hook-form"; import { Else, If, Then, When } from "react-if"; @@ -9,6 +9,8 @@ import { v4 as uuidv4 } from "uuid"; 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 { useModalContext } from "@/context/modal.provider"; import { useDebounce } from "@/hooks/useDebounce"; import { updateArrayState } from "@/utils/array"; @@ -34,7 +36,46 @@ export interface TreeSpeciesInputProps extends Omit error?: FieldErrors[]; } -export type TreeSpeciesValue = { uuid?: string; name?: string; amount?: number; new?: boolean }; +export type TreeSpeciesValue = { uuid?: string; name?: string; taxon_id?: string; amount?: number; new?: boolean }; + +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(); @@ -42,14 +83,16 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const lastInputRef = 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 refTotal = useRef(null); const refTreeSpecies = useRef(null); + const { openModal } = useModalContext(); - const autocompleteSearch = useAutocompleteSearch(); + const { autocompleteSearch, findTaxonId } = useAutocompleteSearch(); const { onChange, value, clearErrors, collection } = props; @@ -86,14 +129,29 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const addValue = (e: React.MouseEvent | KeyboardEvent) => { e.preventDefault(); - if (!props.error) { - if (!props.withNumbers) { - handleCreate?.({ uuid: uuidv4(), name: valueAutoComplete, amount: 0, new: true }); - } else { - handleCreate?.({ uuid: uuidv4(), name: valueAutoComplete, amount: 0 }); - } + if (props.error) return; + + const taxonId = findTaxonId(valueAutoComplete); + + const doAdd = () => { + handleCreate?.({ + uuid: uuidv4(), + name: valueAutoComplete, + taxon_id: taxonId, + amount: props.withNumbers ? 0 : undefined, + // TODO (NJC) this is not correct, but leaving it in for now for testing. + new: !props.withNumbers + }); lastInputRef.current && 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(); } }; @@ -116,8 +174,9 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- 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( + "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." + )}
@@ -128,17 +187,15 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
setValueAutoComplete(e.target.value)} - onSearch={(query: string) => { - if (query === "non-scientific name") return Promise.resolve([]); - return autocompleteSearch(query); - }} - onSelected={item => { - setValueAutoComplete(item); + onSearch={async search => { + const result = await autocompleteSearch(search); + setSearchResult(result); + return result; }} /> 0}> @@ -173,7 +230,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- +
{t("No matches available")} @@ -269,7 +326,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { } >
- + diff --git a/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts index 5f0ff2f5a..a49376af8 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts +++ b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts @@ -4,6 +4,8 @@ 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(); @@ -26,8 +28,8 @@ async function searchRequest(search: string) { } const payload = await response.json(); - const data = payload.data as { attributes: { scientificName: string } }[]; - return data.map(({ attributes }) => attributes.scientificName); + const data = payload.data as { id: string; attributes: { scientificName: string } }[]; + return data.map(({ id, attributes: { scientificName } }) => ({ taxonId: id, scientificName } as ScientificName)); } /** @@ -37,16 +39,33 @@ async function searchRequest(search: string) { * types some characters, then backspaces a couple to type new ones). */ export function useAutocompleteSearch() { - const cache = useMemo(() => new Map(), []); - return useCallback( - async (search: string) => { + 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 cache.get(search); + if (cache.has(search)) return mapNames(cache.get(search) as ScientificName[]); const names = await searchRequest(search); cache.set(search, names); - return 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/tsconfig.json b/tsconfig.json index c95c9a97b..d9bcf2d7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, From ffa083b2c6235a4a71b90590126f63cd3a38c061 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 10 Dec 2024 12:56:51 -0800 Subject: [PATCH 04/15] [TM-1402] Improve auto complete and tree species input UX. --- .../AutoCompleteInput/AutoCompleteInput.tsx | 38 ++++++++++--------- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 37 ++++++++++++++++-- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx index a412cd21f..827ed397d 100644 --- a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx +++ b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx @@ -5,6 +5,7 @@ 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"; @@ -15,6 +16,8 @@ export interface AutoCompleteInputProps extends InputProps { classNameMenu?: string; } +const SEARCH_RESET = { list: [], query: "" }; + //TODO: Bugfix: Users can enter space in this input const AutoCompleteInput = forwardRef( ( @@ -22,7 +25,7 @@ const AutoCompleteInput = forwardRef( 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) => { @@ -32,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" @@ -79,7 +81,7 @@ const AutoCompleteInput = forwardRef( - {list.map(item => ( + {searchResult.list.map(item => ( { const id = useId(); const t = useT(); const lastInputRef = useRef(null); + const autoCompleteRef = useRef(null); const [valueAutoComplete, setValueAutoComplete] = useState(""); const [searchResult, setSearchResult] = useState(); @@ -143,7 +144,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { new: !props.withNumbers }); - lastInputRef.current && lastInputRef.current.focus(); + lastInputRef.current?.focus(); }; if (!isEmpty(searchResult) && taxonId == null) { @@ -155,6 +156,28 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { } }; + 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(); + } + }; + const onKeyDownCapture = (e: KeyboardEvent) => { if (e.key !== "Enter") return; e.preventDefault(); @@ -186,6 +209,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
{ + @@ -311,7 +338,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- {t(`NEW ${value.name}`)} + {t("Editing: {name}", { name: value.name })}
@@ -379,8 +406,10 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { iconProps={{ name: IconNames.EDIT_TA, width: 24 }} className="text-blueCustom-700 hover:text-primary" onClick={() => { + setValueAutoComplete(value.name ?? ""); setEditIndex(value.uuid ?? null); setEditValue(value); + autoCompleteRef.current?.focus(); }} /> Date: Tue, 10 Dec 2024 13:12:13 -0800 Subject: [PATCH 05/15] [TM-1402] Cleanup some more testing UI. --- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 1a1c0347f..2a181d2fd 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -36,7 +36,7 @@ export interface TreeSpeciesInputProps extends Omit error?: FieldErrors[]; } -export type TreeSpeciesValue = { uuid?: string; name?: string; taxon_id?: string; amount?: number; new?: boolean }; +export type TreeSpeciesValue = { uuid?: string; name?: string; taxon_id?: string; amount?: number }; const NonScientificConfirmationModal = ({ onConfirm }: { onConfirm: () => void }) => { const t = useT(); @@ -139,9 +139,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { uuid: uuidv4(), name: valueAutoComplete, taxon_id: taxonId, - amount: props.withNumbers ? 0 : undefined, - // TODO (NJC) this is not correct, but leaving it in for now for testing. - new: !props.withNumbers + amount: props.withNumbers ? 0 : undefined }); lastInputRef.current?.focus(); @@ -356,7 +354,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { - + @@ -391,34 +389,23 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { containerClassName="" />
- - - 7,400 - - - -
- { - setValueAutoComplete(value.name ?? ""); - setEditIndex(value.uuid ?? null); - setEditValue(value); - autoCompleteRef.current?.focus(); - }} - /> - setDeleteIndex(value.uuid ?? null)} - /> -
-
+
+ { + setValueAutoComplete(value.name ?? ""); + setEditIndex(value.uuid ?? null); + setEditValue(value); + autoCompleteRef.current?.focus(); + }} + /> + setDeleteIndex(value.uuid ?? null)} + /> +
)} /> From 946a71dfbed771fa28ca78438ead33afaff79c75 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 10 Dec 2024 20:56:49 -0800 Subject: [PATCH 06/15] [TM-1402] Clear out the auto complete box when a new species is added. --- .../elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 2a181d2fd..8723ee454 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -142,6 +142,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { amount: props.withNumbers ? 0 : undefined }); + setValueAutoComplete(""); lastInputRef.current?.focus(); }; From 2921b523dbaf350d4fab772a45bbe3109cfbdfb7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 11 Dec 2024 14:47:43 -0800 Subject: [PATCH 07/15] [TM-1402] Define an entity context for forms. --- .../components/EntityEdit/EntityEdit.tsx | 53 +++++----- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 23 +++-- src/context/entity.provider.tsx | 20 ++++ .../edit/[uuid]/EditEntityForm.tsx | 97 ++++++++++--------- src/types/common.ts | 8 ++ 5 files changed, 123 insertions(+), 78 deletions(-) create mode 100644 src/context/entity.provider.tsx 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/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 8723ee454..cd5b7a345 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -10,8 +10,10 @@ import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesI import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; +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"; @@ -26,7 +28,6 @@ export interface TreeSpeciesInputProps extends Omit title: string; buttonCaptionSuffix: string; withNumbers?: boolean; - withTreeSearch?: boolean; value: TreeSpeciesValue[]; onChange: (value: any[]) => void; clearErrors: () => void; @@ -97,6 +98,12 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const { onChange, value, clearErrors, collection } = props; + const { entityUuid, entityName } = useEntityContext(); + const handleBaseEntityTrees = + entityName != null && + entityUuid != null && + (isReportModelName(entityName) || ["sites", "nurseries"].includes(entityName)); + const handleCreate = useDebounce( useCallback( (treeValue: TreeSpeciesValue) => { @@ -193,14 +200,14 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { feedbackRequired={props.feedbackRequired} >
- + {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:")} @@ -353,10 +360,14 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { >
- +
+ +
- - + +
+ +
{t(value.name)} diff --git a/src/context/entity.provider.tsx b/src/context/entity.provider.tsx new file mode 100644 index 000000000..a592c007d --- /dev/null +++ b/src/context/entity.provider.tsx @@ -0,0 +1,20 @@ +import { createContext, ReactNode, useContext } from "react"; + +import { EntityName } from "@/types/common"; + +interface IEntityContext { + entityUuid?: string; + entityName?: EntityName; +} + +const EntityContext = createContext({}); + +type EntityFrameworkProviderProps = { entityUuid: string; entityName: EntityName; children: ReactNode }; + +const EntityProvider = ({ children, entityUuid, entityName }: EntityFrameworkProviderProps) => ( + {children} +); + +export const useEntityContext = () => useContext(EntityContext); + +export default EntityProvider; diff --git a/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx b/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx index 1e7eaa556..8b7876904 100644 --- a/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx +++ b/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { useMemo } from "react"; import WizardForm from "@/components/extensive/WizardForm"; +import EntityProvider from "@/context/entity.provider"; import { useFrameworkContext } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, @@ -88,54 +89,56 @@ const EditEntityForm = ({ entityName, entityUUID, entity, formData }: EditEntity }, [formSteps, mode]); return ( - router.push("/home")} - onChange={(data, closeAndSave?: boolean) => - updateEntity({ - pathParams: { uuid: entityUUID, entity: entityName }, - // @ts-ignore - body: { - answers: normalizedFormData(data, formSteps!), - ...(closeAndSave ? { continue_later_action: true } : {}) - } - }) - } - formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} - onSubmit={() => - submitEntity({ - pathParams: { - entity: entityName, - uuid: entityUUID - } - }) - } - submitButtonDisable={isSubmitting} - defaultValues={defaultValues} - title={formTitle} - tabOptions={{ - markDone: true, - disableFutureTabs: true - }} - summaryOptions={{ - title: t("Review Details"), - downloadButtonText: t("Download") - }} - roundedCorners - saveAndCloseModal={{ - content: - saveAndCloseModalMapping[entityName] ?? - t( - "You have made progress on this form. If you close the form now, your progress will be saved for when you come back. You can access this form again on the reporting tasks section under your project page. Would you like to close this form and continue later?" - ), - onConfirm() { - router.push(getEntityDetailPageLink(entityName, entityUUID)); + + router.push("/home")} + onChange={(data, closeAndSave?: boolean) => + updateEntity({ + pathParams: { uuid: entityUUID, entity: entityName }, + // @ts-ignore + body: { + answers: normalizedFormData(data, formSteps!), + ...(closeAndSave ? { continue_later_action: true } : {}) + } + }) + } + formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} + onSubmit={() => + submitEntity({ + pathParams: { + entity: entityName, + uuid: entityUUID + } + }) } - }} - {...initialStepProps} - /> + submitButtonDisable={isSubmitting} + defaultValues={defaultValues} + title={formTitle} + tabOptions={{ + markDone: true, + disableFutureTabs: true + }} + summaryOptions={{ + title: t("Review Details"), + downloadButtonText: t("Download") + }} + roundedCorners + saveAndCloseModal={{ + content: + saveAndCloseModalMapping[entityName] ?? + t( + "You have made progress on this form. If you close the form now, your progress will be saved for when you come back. You can access this form again on the reporting tasks section under your project page. Would you like to close this form and continue later?" + ), + onConfirm() { + router.push(getEntityDetailPageLink(entityName, entityUUID)); + } + }} + {...initialStepProps} + /> + ); }; diff --git a/src/types/common.ts b/src/types/common.ts index ee23ca02b..90d155be4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -222,10 +222,18 @@ export type EntityName = BaseModelNames | ReportsModelNames; export type BaseModelNames = "projects" | "sites" | "nurseries" | "project-pitches"; export type ReportsModelNames = "project-reports" | "site-reports" | "nursery-reports"; +export const isBaseModelName = (name: EntityName): name is BaseModelNames => !name.endsWith("-reports"); +export const isReportModelName = (name: EntityName): name is ReportsModelNames => name.endsWith("-reports"); + export type SingularEntityName = SingularBaseModelNames | SingularReportsModelNames; export type SingularBaseModelNames = "project" | "site" | "nursery" | "project-pitch"; export type SingularReportsModelNames = "project-report" | "site-report" | "nursery-report"; +export const isSingularBaseModelName = (name: SingularEntityName): name is SingularBaseModelNames => + !name.endsWith("-report"); +export const isSingularReportModelName = (name: SingularEntityName): name is SingularReportsModelNames => + name.endsWith("-report"); + export type Entity = { entityName: EntityName | SingularEntityName; entityUUID: string; From f04a7d59348c20e993970cbc6e0da870e7950a41 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 11 Dec 2024 15:39:11 -0800 Subject: [PATCH 08/15] [TM-1402] Wired up the "new" tag against the new establishment tree data api. --- openapi-codegen.config.ts | 20 ++- package.json | 3 +- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 11 +- src/connections/EstablishmentTrees.ts | 58 +++++++++ .../entityService/entityServiceComponents.ts | 115 ++++++++++++++++++ .../v3/entityService/entityServiceFetcher.ts | 6 + .../entityService/entityServicePredicates.ts | 43 +++++++ .../v3/entityService/entityServiceSchemas.ts | 20 +++ src/store/apiSlice.ts | 4 +- 9 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 src/connections/EstablishmentTrees.ts create mode 100644 src/generated/v3/entityService/entityServiceComponents.ts create mode 100644 src/generated/v3/entityService/entityServiceFetcher.ts create mode 100644 src/generated/v3/entityService/entityServicePredicates.ts create mode 100644 src/generated/v3/entityService/entityServiceSchemas.ts 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..3297ed9c0 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": "npm run generate:userService && npm run 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/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index cd5b7a345..71350cc68 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -10,6 +10,7 @@ import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesI import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { EstablishmentEntityType, useEstablimentTrees } from "@/connections/EstablishmentTrees"; import { useEntityContext } from "@/context/entity.provider"; import { useModalContext } from "@/context/modal.provider"; import { useDebounce } from "@/hooks/useDebounce"; @@ -104,6 +105,10 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { entityUuid != null && (isReportModelName(entityName) || ["sites", "nurseries"].includes(entityName)); + const entity = (handleBaseEntityTrees ? entityName : undefined) as EstablishmentEntityType; + const uuid = handleBaseEntityTrees ? entityUuid : undefined; + const [, { establishmentTrees }] = useEstablimentTrees({ entity, uuid }); + const handleCreate = useDebounce( useCallback( (treeValue: TreeSpeciesValue) => { @@ -364,7 +369,11 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- +
diff --git a/src/connections/EstablishmentTrees.ts b/src/connections/EstablishmentTrees.ts new file mode 100644 index 000000000..da28062b6 --- /dev/null +++ b/src/connections/EstablishmentTrees.ts @@ -0,0 +1,58 @@ +import { createSelector } from "reselect"; + +import { + establishmentTreesFind, + EstablishmentTreesFindPathParams +} from "@/generated/v3/entityService/entityServiceComponents"; +import { establishmentTreesFindFetchFailed } from "@/generated/v3/entityService/entityServicePredicates"; +import { ApiDataStore } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; +import { connectionHook } from "@/utils/connectionShortcuts"; +import { selectorCache } from "@/utils/selectorCache"; + +type EstablishmentTreesConnection = { + establishmentTrees?: string[]; + + establishmentTreesLoadFailed: boolean; +}; + +type EstablishmentTreesProps = Partial; + +export type EstablishmentEntityType = EstablishmentTreesFindPathParams["entity"] | undefined; +const establishmentTreesSelector = + (entity: EstablishmentEntityType, uuid: string | undefined) => (store: ApiDataStore) => + entity == null || uuid == null ? undefined : store.establishmentTrees?.[`${entity}|${uuid}`]; +const establishmentTreesLoadFailed = + (entity: EstablishmentEntityType, uuid: string | undefined) => (store: ApiDataStore) => + entity == null || uuid == null + ? false + : establishmentTreesFindFetchFailed({ pathParams: { entity, uuid } })(store) != null; + +const connectionIsLoaded = ( + { establishmentTrees, establishmentTreesLoadFailed }: EstablishmentTreesConnection, + { entity, uuid }: EstablishmentTreesProps +) => entity == null || uuid == null || establishmentTrees != null || establishmentTreesLoadFailed; + +const establishmentTreesConnection: Connection = { + load: (connection, props) => { + if (!connectionIsLoaded(connection, props)) { + establishmentTreesFind({ pathParams: { entity: props.entity!, uuid: props.uuid! } }); + } + }, + + isLoaded: connectionIsLoaded, + + selector: selectorCache( + ({ entity, uuid }) => `${entity}|${uuid}`, + ({ entity, uuid }) => + createSelector( + [establishmentTreesSelector(entity, uuid), establishmentTreesLoadFailed(entity, uuid)], + (treesDto, establishmentTreesLoadFailed) => ({ + establishmentTrees: treesDto?.attributes?.establishmentTrees, + establishmentTreesLoadFailed + }) + ) + ) +}; + +export const useEstablimentTrees = connectionHook(establishmentTreesConnection); diff --git a/src/generated/v3/entityService/entityServiceComponents.ts b/src/generated/v3/entityService/entityServiceComponents.ts new file mode 100644 index 000000000..964c60fcf --- /dev/null +++ b/src/generated/v3/entityService/entityServiceComponents.ts @@ -0,0 +1,115 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +import type * as Fetcher from "./entityServiceFetcher"; +import { entityServiceFetch } from "./entityServiceFetcher"; +import type * as Schemas from "./entityServiceSchemas"; + +export type TreeScientificNamesSearchQueryParams = { + search: string; +}; + +export type TreeScientificNamesSearchError = Fetcher.ErrorWrapper; + +export type TreeScientificNamesSearchResponse = { + data?: { + /** + * @example treeSpeciesScientificNames + */ + type?: string; + id?: string; + attributes?: Schemas.ScientificNameDto; + }[]; +}; + +export type TreeScientificNamesSearchVariables = { + queryParams: TreeScientificNamesSearchQueryParams; +}; + +/** + * Search scientific names of tree species. Returns up to 10 entries. + */ +export const treeScientificNamesSearch = (variables: TreeScientificNamesSearchVariables, signal?: AbortSignal) => + entityServiceFetch< + TreeScientificNamesSearchResponse, + TreeScientificNamesSearchError, + undefined, + {}, + TreeScientificNamesSearchQueryParams, + {} + >({ url: "/trees/v3/scientific-names", method: "get", ...variables, signal }); + +export type EstablishmentTreesFindPathParams = { + /** + * Entity type for which to retrieve the establishment tree data. + */ + entity: "sites" | "nurseries" | "project-reports" | "site-reports" | "nursery-reports"; + /** + * Entity UUID for which to retrieve the establishment tree data. + */ + uuid: string; +}; + +export type EstablishmentTreesFindError = Fetcher.ErrorWrapper< + | { + status: 400; + payload: { + /** + * @example 400 + */ + statusCode: number; + /** + * @example Bad Request + */ + message: string; + /** + * @example Bad Request + */ + error?: string; + }; + } + | { + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + /** + * @example Unauthorized + */ + error?: string; + }; + } +>; + +export type EstablishmentTreesFindResponse = { + data?: { + /** + * @example establishmentTrees + */ + type?: string; + id?: string; + attributes?: Schemas.EstablishmentsTreesDto; + }; +}; + +export type EstablishmentTreesFindVariables = { + pathParams: EstablishmentTreesFindPathParams; +}; + +export const establishmentTreesFind = (variables: EstablishmentTreesFindVariables, signal?: AbortSignal) => + entityServiceFetch< + EstablishmentTreesFindResponse, + EstablishmentTreesFindError, + undefined, + {}, + {}, + EstablishmentTreesFindPathParams + >({ url: "/trees/v3/establishments/{entity}/{uuid}", method: "get", ...variables, signal }); diff --git a/src/generated/v3/entityService/entityServiceFetcher.ts b/src/generated/v3/entityService/entityServiceFetcher.ts new file mode 100644 index 000000000..b2ae84123 --- /dev/null +++ b/src/generated/v3/entityService/entityServiceFetcher.ts @@ -0,0 +1,6 @@ +// This type is imported in the auto generated `userServiceComponents` file, so it needs to be +// exported from this file. +export type { ErrorWrapper } from "../utils"; + +// The serviceFetch method is the shared fetch method for all service fetchers. +export { serviceFetch as entityServiceFetch } from "../utils"; diff --git a/src/generated/v3/entityService/entityServicePredicates.ts b/src/generated/v3/entityService/entityServicePredicates.ts new file mode 100644 index 000000000..c27dd2821 --- /dev/null +++ b/src/generated/v3/entityService/entityServicePredicates.ts @@ -0,0 +1,43 @@ +import { isFetching, fetchFailed } from "../utils"; +import { ApiDataStore } from "@/store/apiSlice"; +import { + TreeScientificNamesSearchQueryParams, + TreeScientificNamesSearchVariables, + EstablishmentTreesFindPathParams, + EstablishmentTreesFindVariables +} from "./entityServiceComponents"; + +export const treeScientificNamesSearchIsFetching = + (variables: TreeScientificNamesSearchVariables) => (store: ApiDataStore) => + isFetching({ + store, + url: "/trees/v3/scientific-names", + method: "get", + ...variables + }); + +export const treeScientificNamesSearchFetchFailed = + (variables: TreeScientificNamesSearchVariables) => (store: ApiDataStore) => + fetchFailed({ + store, + url: "/trees/v3/scientific-names", + method: "get", + ...variables + }); + +export const establishmentTreesFindIsFetching = (variables: EstablishmentTreesFindVariables) => (store: ApiDataStore) => + isFetching<{}, EstablishmentTreesFindPathParams>({ + store, + url: "/trees/v3/establishments/{entity}/{uuid}", + method: "get", + ...variables + }); + +export const establishmentTreesFindFetchFailed = + (variables: EstablishmentTreesFindVariables) => (store: ApiDataStore) => + fetchFailed<{}, EstablishmentTreesFindPathParams>({ + store, + url: "/trees/v3/establishments/{entity}/{uuid}", + method: "get", + ...variables + }); diff --git a/src/generated/v3/entityService/entityServiceSchemas.ts b/src/generated/v3/entityService/entityServiceSchemas.ts new file mode 100644 index 000000000..1ffeebecd --- /dev/null +++ b/src/generated/v3/entityService/entityServiceSchemas.ts @@ -0,0 +1,20 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +export type ScientificNameDto = { + /** + * The scientific name for this tree species + * + * @example Abelia uniflora + */ + scientificName: string; +}; + +export type EstablishmentsTreesDto = { + /** + * The species that were specified at the establishment of the parent entity. + */ + establishmentTrees: string[]; +}; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 7f15b433a..b4a7eb2b4 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -5,6 +5,7 @@ import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; +import { EstablishmentsTreesDto } from "@/generated/v3/entityService/entityServiceSchemas"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -53,9 +54,10 @@ type StoreResourceMap = Record; logins: StoreResourceMap; organisations: StoreResourceMap; users: StoreResourceMap; From 9a07c46c8218fb20d8d79c38894ad5d1929dae6a Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 11 Dec 2024 16:37:47 -0800 Subject: [PATCH 09/15] [TM-1402] Missing parens in hover text. --- .../elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 71350cc68..a3809fcbc 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -374,7 +374,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { establishmentTrees != null && value.name != null && !establishmentTrees.includes(value.name) } > -
+
From 20404f251e4008db01872d2ae8b9e3b865877bd2 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 11 Dec 2024 17:22:38 -0800 Subject: [PATCH 10/15] [TM-1402] Switch to yarn --- package.json | 2 +- src/generated/v3/userService/userServiceComponents.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3297ed9c0..d184e19fb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "generate:api": "openapi-codegen gen api", "generate:userService": "openapi-codegen gen userService", "generate:entityService": "openapi-codegen gen entityService", - "generate:services": "npm run generate:userService && npm run generate: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/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index 6a60c063d..179fa8d37 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -57,6 +57,8 @@ export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) = export type UsersFindPathParams = { /** * A valid user id or "me" + * + * @example me */ id: string; }; From c22fdd87e7d1a706bc63d46587cb509a16a8ba7e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 12 Dec 2024 09:46:56 -0800 Subject: [PATCH 11/15] [TM-1402] Some cleanup on layout logic and translations. --- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index a3809fcbc..40eb73fd0 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -91,7 +91,6 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const [deleteIndex, setDeleteIndex] = useState(null); const [editValue, setEditValue] = useState(null); const refPlanted = useRef(null); - const refTotal = useRef(null); const refTreeSpecies = useRef(null); const { openModal } = useModalContext(); @@ -100,10 +99,9 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const { onChange, value, clearErrors, collection } = props; const { entityUuid, entityName } = useEntityContext(); - const handleBaseEntityTrees = - entityName != null && - entityUuid != null && - (isReportModelName(entityName) || ["sites", "nurseries"].includes(entityName)); + 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; @@ -282,10 +280,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
-
+
{props.title} @@ -293,21 +288,21 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { {props.value.length}
-
+
- {props.withNumbers ? "TREES TO BE PLANTED:" : "SPECIES PLANTED:"} + {isReport ? t("SPECIES PLANTED:") : t("TREES TO BE PLANTED:")} {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0) : "0"}
- -
+ +
- {"TOTAL PLANTED TO DATE:"} + {t("TOTAL PLANTED TO DATE:")} - 47,800 + TODO
@@ -324,7 +319,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { "blur-sm": editIndex && editIndex !== value.uuid })} > - +
{t(`Are you sure you want to delete “${value.name}”?`)} @@ -369,11 +364,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- +
@@ -386,11 +377,11 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
Date: Thu, 12 Dec 2024 13:53:50 -0800 Subject: [PATCH 12/15] [TM-1402] Integrate previous planting counts. --- .../TreeSpeciesInput/TreeSpeciesInput.tsx | 24 +++++++++++++++---- src/connections/EstablishmentTrees.ts | 7 ++++-- .../v3/entityService/entityServiceSchemas.ts | 8 +++++++ src/store/store.ts | 5 ++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index 40eb73fd0..01ef90959 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -1,7 +1,7 @@ import { useT } from "@transifex/react"; import classNames from "classnames"; import { isEmpty, remove } from "lodash"; -import { Fragment, KeyboardEvent, useCallback, useId, useRef, useState } from "react"; +import { Fragment, KeyboardEvent, useCallback, useId, useMemo, useRef, useState } from "react"; import { FieldError, FieldErrors } from "react-hook-form"; import { Else, If, Then, When } from "react-if"; import { v4 as uuidv4 } from "uuid"; @@ -10,7 +10,7 @@ import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesI import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; -import { EstablishmentEntityType, useEstablimentTrees } from "@/connections/EstablishmentTrees"; +import { EstablishmentEntityType, useEstablishmentTrees } from "@/connections/EstablishmentTrees"; import { useEntityContext } from "@/context/entity.provider"; import { useModalContext } from "@/context/modal.provider"; import { useDebounce } from "@/hooks/useDebounce"; @@ -105,7 +105,16 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const entity = (handleBaseEntityTrees ? entityName : undefined) as EstablishmentEntityType; const uuid = handleBaseEntityTrees ? entityUuid : undefined; - const [, { establishmentTrees }] = useEstablimentTrees({ entity, uuid }); + 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( @@ -293,7 +302,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { {isReport ? t("SPECIES PLANTED:") : t("TREES TO BE PLANTED:")} - {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0) : "0"} + {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0).toLocaleString() : "0"}
@@ -302,7 +311,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { {t("TOTAL PLANTED TO DATE:")} - TODO + {totalWithPrevious.toLocaleString()}
@@ -401,6 +410,11 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { containerClassName="" />
+ + + {(previousPlantingCounts?.[value.name ?? ""] ?? 0).toLocaleString()} + +
({ establishmentTrees: treesDto?.attributes?.establishmentTrees, + previousPlantingCounts: treesDto?.attributes?.previousPlantingCounts, establishmentTreesLoadFailed }) ) ) }; -export const useEstablimentTrees = connectionHook(establishmentTreesConnection); +export const useEstablishmentTrees = connectionHook(establishmentTreesConnection); diff --git a/src/generated/v3/entityService/entityServiceSchemas.ts b/src/generated/v3/entityService/entityServiceSchemas.ts index 1ffeebecd..bb11f5e3f 100644 --- a/src/generated/v3/entityService/entityServiceSchemas.ts +++ b/src/generated/v3/entityService/entityServiceSchemas.ts @@ -17,4 +17,12 @@ export type EstablishmentsTreesDto = { * The species that were specified at the establishment of the parent entity. */ establishmentTrees: string[]; + /** + * If the entity in this request is a report, the sum totals of previous planting by species. + * + * @example {"Aster persaliens":256,"Cirsium carniolicum":1024} + */ + previousPlantingCounts: { + [key: string]: number; + } | null; }; diff --git a/src/store/store.ts b/src/store/store.ts index 4b9679305..e40d73ac2 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -36,6 +36,11 @@ export const makeStore = () => { ApiSlice.redux = store; + if (typeof window !== "undefined") { + // Make some things available to the browser console for easy debugging. + (window as any).terramatch.getApiState = () => store.getState(); + } + return store; }; From 5740c64689408f44e707de32c0932d5d26dc6b60 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 12 Dec 2024 16:33:40 -0800 Subject: [PATCH 13/15] [TM-1402] Run the test suite on all pull requests. --- .github/workflows/pull-request.yml | 1 - 1 file changed, 1 deletion(-) 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 From 64584f4167aa0689e8f69bb45e618b19f98b61c9 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 12 Dec 2024 16:42:15 -0800 Subject: [PATCH 14/15] [TM-1402] Don't try to set the debug method in test envs. --- src/store/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/store.ts b/src/store/store.ts index e40d73ac2..768fd08dd 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -36,7 +36,7 @@ export const makeStore = () => { ApiSlice.redux = store; - if (typeof window !== "undefined") { + if (typeof window !== "undefined" && (window as any).terramatch != null) { // Make some things available to the browser console for easy debugging. (window as any).terramatch.getApiState = () => store.getState(); } From f0cae93f5094ae9b635df3d3fb51c08f1a986684 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 12 Dec 2024 16:50:36 -0800 Subject: [PATCH 15/15] [TM-1402] Regenerate storyshot for tree species input. --- .../AutoCompleteInput.stories.storyshot | 1 - .../TreeSpeciesInput.stories.storyshot | 100 ++++++++++-------- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/src/components/elements/Inputs/AutoCompleteInput/__snapshots__/AutoCompleteInput.stories.storyshot b/src/components/elements/Inputs/AutoCompleteInput/__snapshots__/AutoCompleteInput.stories.storyshot index 3511a54b0..2c15a558a 100644 --- a/src/components/elements/Inputs/AutoCompleteInput/__snapshots__/AutoCompleteInput.stories.storyshot +++ b/src/components/elements/Inputs/AutoCompleteInput/__snapshots__/AutoCompleteInput.stories.storyshot @@ -35,7 +35,6 @@ exports[`Storyshots Components/Elements/Inputs/AutoComplete Default 1`] = ` className="w-full outline-none transition-all duration-300 ease-in-out focus:ring-transparent px-3 py-[9px] rounded-lg focus:border-primary-500 border border-neutral-200" data-headlessui-state="" id=":r9:" - onChangeCapture={[Function]} onClick={[Function]} onKeyUp={[Function]} onMouseDown={[Function]} diff --git a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot index 0b132a4d0..9810e81fc 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot +++ b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot @@ -22,19 +22,6 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` id=":r23:-description" />
-
-
- 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. -
@@ -69,7 +56,6 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` id=":r26:" name="treeSpecies" onChange={[Function]} - onChangeCapture={[Function]} onClick={[Function]} onKeyUp={[Function]} onMouseDown={[Function]} @@ -100,7 +86,7 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` className="mb-1 mt-9 flex gap-6 border-b pb-4" >

-
-

- SPECIES PLANTED: -

-

- 0 -

-
@@ -136,13 +106,13 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` className="uppercase text-black text-14-bold" data-testid="txt" > - TOTAL PLANTED TO DATE: + TREES TO BE PLANTED:

- 47,800 + 0

@@ -163,6 +133,18 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = `
+
+
+

-

- 7,400 -

+ + +
@@ -275,7 +276,6 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`] id=":r2b:" name="treeSpecies" onChange={[Function]} - onChangeCapture={[Function]} onClick={[Function]} onKeyUp={[Function]} onMouseDown={[Function]} @@ -353,6 +353,18 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`]
+
+
+