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/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx index 80fa54ab2..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"; @@ -13,17 +14,18 @@ export interface AutoCompleteInputProps extends InputProps { onSearch: (query: string) => Promise; disableAutoComplete?: boolean; classNameMenu?: string; - onSelected?: (item: string) => void; } +const SEARCH_RESET = { list: [], query: "" }; + //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(); - const [list, setList] = useState([]); + const [searchResult, setSearchResult] = useState<{ list: string[]; query: string }>(SEARCH_RESET); const [loading, setLoading] = useState(false); const onSelect = (item: string) => { @@ -33,37 +35,34 @@ const AutoCompleteInput = forwardRef( inputProps.onChange?.({ target: { name: inputProps.name, value: item } } as ChangeEvent); } - onSelected?.(item); - - 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" @@ -82,7 +81,7 @@ const AutoCompleteInput = forwardRef( - {list.map(item => ( + {searchResult.list.map(item => ( title: string; buttonCaptionSuffix: string; withNumbers?: boolean; - withTreeSearch?: boolean; value: TreeSpeciesValue[]; onChange: (value: any[]) => void; clearErrors: () => void; @@ -33,23 +38,84 @@ 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 }; + +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 refTotal = 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) => { @@ -83,14 +149,50 @@ 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 + }); - lastInputRef.current && lastInputRef.current.focus(); + 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(); } }; @@ -110,13 +212,14 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { feedbackRequired={props.feedbackRequired} >
- + {handleBaseEntityTrees && (
- 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." + )}
-
+ )}
{t("Scientific Name:")} @@ -124,26 +227,17 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
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" - ]); - }} - onSelected={item => { - console.log(item); - setValueAutoComplete(item); + onSearch={async search => { + const result = await autocompleteSearch(search); + setSearchResult(result); + return result; }} /> 0}> @@ -165,20 +259,23 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { +
- +
{t("No matches available")} @@ -186,16 +283,13 @@ 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.")}
-
+
{props.title} @@ -203,21 +297,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"} + {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0).toLocaleString() : "0"}
- -
+ +
- {"TOTAL PLANTED TO DATE:"} + {t("TOTAL PLANTED TO DATE:")} - 47,800 + {totalWithPrevious.toLocaleString()}
@@ -234,7 +328,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { "blur-sm": editIndex && editIndex !== value.uuid })} > - +
{t(`Are you sure you want to delete “${value.name}”?`)} @@ -259,7 +353,7 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
- {t(`NEW ${value.name}`)} + {t("Editing: {name}", { name: value.name })}
@@ -274,11 +368,15 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { } >
- - + +
+ +
- - + +
+ +
{t(value.name)} @@ -288,11 +386,11 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => {
{ containerClassName="" />
- - - 7,400 + + + {(previousPlantingCounts?.[value.name ?? ""] ?? 0).toLocaleString()} - -
- { - setEditIndex(value.uuid ?? null); - setEditValue(value); - }} - /> - setDeleteIndex(value.uuid ?? null)} - /> -
-
+
+ { + 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 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`]
+
+
+

({ 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/connections/EstablishmentTrees.ts b/src/connections/EstablishmentTrees.ts new file mode 100644 index 000000000..ca30a376d --- /dev/null +++ b/src/connections/EstablishmentTrees.ts @@ -0,0 +1,61 @@ +import { createSelector } from "reselect"; + +import { + establishmentTreesFind, + EstablishmentTreesFindPathParams +} from "@/generated/v3/entityService/entityServiceComponents"; +import { establishmentTreesFindFetchFailed } from "@/generated/v3/entityService/entityServicePredicates"; +import { EstablishmentsTreesDto } from "@/generated/v3/entityService/entityServiceSchemas"; +import { ApiDataStore } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; +import { connectionHook } from "@/utils/connectionShortcuts"; +import { selectorCache } from "@/utils/selectorCache"; + +type EstablishmentTreesConnection = { + establishmentTrees?: EstablishmentsTreesDto["establishmentTrees"]; + previousPlantingCounts?: EstablishmentsTreesDto["previousPlantingCounts"]; + + 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, + previousPlantingCounts: treesDto?.attributes?.previousPlantingCounts, + establishmentTreesLoadFailed + }) + ) + ) +}; + +export const useEstablishmentTrees = connectionHook(establishmentTreesConnection); 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/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/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..bb11f5e3f --- /dev/null +++ b/src/generated/v3/entityService/entityServiceSchemas.ts @@ -0,0 +1,28 @@ +/** + * 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[]; + /** + * 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/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; }; 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) => { 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/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; 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; diff --git a/src/store/store.ts b/src/store/store.ts index 4b9679305..768fd08dd 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" && (window as any).terramatch != null) { + // Make some things available to the browser console for easy debugging. + (window as any).terramatch.getApiState = () => store.getState(); + } + return store; }; 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; 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,