diff --git a/Gemfile.lock b/Gemfile.lock index 9c3325c3e8..5a7745dc89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -67,7 +67,7 @@ GEM zeitwerk (~> 2.3) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - amq-protocol (2.3.2) + amq-protocol (2.3.3) ast (2.4.2) base64 (0.2.0) bcrypt (3.1.20) @@ -104,7 +104,7 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.5.1) + diff-lcs (1.6.0) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) discard (1.4.0) @@ -112,8 +112,8 @@ GEM docile (1.4.1) e2mmap (0.1.0) erubi (1.13.1) - factory_bot (6.5.0) - activesupport (>= 5.0.0) + factory_bot (6.5.1) + activesupport (>= 6.1.0) factory_bot_rails (6.4.4) factory_bot (~> 6.5) railties (>= 5.0.0) @@ -139,7 +139,7 @@ GEM retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.22.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.49.0) + google-apis-storage_v1 (0.50.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.7.1) google-cloud-env (>= 1.0, < 3.a) @@ -147,12 +147,12 @@ GEM google-cloud-env (2.2.1) faraday (>= 1.0, < 3.a) google-cloud-errors (1.4.0) - google-cloud-storage (1.54.0) + google-cloud-storage (1.55.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (~> 0.13) google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (~> 0.38) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) @@ -167,10 +167,11 @@ GEM signet (>= 0.16, < 2.a) hashdiff (1.1.2) hashie (4.1.0) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.9.1) + json (2.10.1) jsonapi-renderer (0.2.2) jwt (2.10.1) base64 @@ -186,7 +187,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - logger (1.6.5) + logger (1.6.6) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) @@ -210,23 +211,23 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.5) + net-imap (0.5.6) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.2-aarch64-linux-gnu) + nokogiri (1.18.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.2-x86_64-linux-gnu) + nokogiri (1.18.3-x86_64-linux-gnu) racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) - parser (3.3.7.0) + parser (3.3.7.1) ast (~> 2.4.1) racc passenger (6.0.23) @@ -247,7 +248,7 @@ GEM hashie (~> 4.1) multi_json (~> 1.15) racc (1.8.1) - rack (2.2.10) + rack (2.2.11) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -303,13 +304,13 @@ GEM actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.4.0) - rollbar (3.6.0) + rexml (3.4.1) + rollbar (3.6.1) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.2) + rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) @@ -331,7 +332,7 @@ GEM scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) - scout_apm (5.6.0) + scout_apm (5.6.1) parser secure_headers (7.1.0) set (1.1.1) @@ -369,13 +370,13 @@ GEM tzinfo-data (1.2025.1) tzinfo (>= 1.0.0) uber (0.1.0) - uri (1.0.2) + uri (1.0.3) valid_url (0.0.4) addressable rails warden (1.2.9) rack (>= 2.0.9) - webmock (3.24.0) + webmock (3.25.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -384,7 +385,7 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.1) + zeitwerk (2.7.2) PLATFORMS aarch64-linux diff --git a/frontend/__test_support__/additional_mocks.tsx b/frontend/__test_support__/additional_mocks.tsx index 7ecb5b793f..43577a2752 100644 --- a/frontend/__test_support__/additional_mocks.tsx +++ b/frontend/__test_support__/additional_mocks.tsx @@ -13,7 +13,7 @@ window.location = { ancestorOrigins, pathname: "", href: "", hash: "", search: "", hostname: "", origin: "", port: "", protocol: "", host: "", -}; +} as unknown as Location & string; console.error = jest.fn(); // enzyme @@ -43,6 +43,7 @@ global.mockNavigate = jest.fn(() => jest.fn()); jest.mock("react-router", () => ({ BrowserRouter: jest.fn(({ children }) =>
{children}
), + MemoryRouter: jest.fn(({ children }) =>
{children}
), Route: jest.fn(({ children }) =>
{children}
), Routes: jest.fn(({ children }) =>
{children}
), useNavigate: () => mockNavigate, diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index a4a59c1ef3..664ce68788 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -10,6 +10,7 @@ import * as THREE from "three"; import React, { ReactNode } from "react"; import { TransitionFn, UseSpringProps } from "@react-spring/three"; import { ThreeElements } from "@react-three/fiber"; +import { Cloud, Clouds, Image, Tube } from "@react-three/drei"; const GroupForTests = (props: ThreeElements["group"]) => // @ts-expect-error Property does not exist on type JSX.IntrinsicElements @@ -612,8 +613,9 @@ jest.mock("@react-three/drei", () => {
{name}
, Trail: ({ name }: { name: string }) =>
{name}
, - Tube: ({ name, children }: { name: string, children: ReactNode }) => -
{children}
, + Tube: (props: React.ComponentProps) => + // @ts-expect-error geometry props not assignable to div +
{props.children}
, Center: ({ children }: { children: ReactNode }) =>
{children}
, Text3D: ({ children }: { children: ReactNode }) => @@ -642,12 +644,15 @@ jest.mock("@react-three/drei", () => {
{name}
, Billboard: ({ name, children }: { name: string, children: ReactNode }) =>
{children}
, - Image: ({ name, url }: { name: string, url: string }) => -
{name} {url}
, - Clouds: ({ name }: { name: string }) => -
{name}
, - Cloud: ({ name }: { name: string }) => -
{name}
, + Image: (props: React.ComponentProps) => + // @ts-expect-error geometry props not assignable to div +
{props.name} {props.url}
, + Clouds: (props: React.ComponentProps) => + // @ts-expect-error geometry props not assignable to div +
{props.children}
, + Cloud: (props: React.ComponentProps) => + // @ts-expect-error geometry props not assignable to div +
, OrthographicCamera: ({ name }: { name: string }) =>
{name}
, }; diff --git a/frontend/__tests__/hotkeys_test.tsx b/frontend/__tests__/hotkeys_test.tsx index 2d38712ebc..18b81c9ab3 100644 --- a/frontend/__tests__/hotkeys_test.tsx +++ b/frontend/__tests__/hotkeys_test.tsx @@ -1,6 +1,5 @@ const mockSyncThunk = jest.fn(); jest.mock("../devices/actions", () => ({ sync: () => mockSyncThunk })); -jest.mock("../farm_designer/map/actions", () => ({ unselectPlant: jest.fn() })); import { fakeState } from "../__test_support__/fake_state"; const mockState = fakeState(); @@ -16,10 +15,10 @@ import { HotKey, HotKeys, HotKeysProps, hotkeysWithActions, toggleHotkeyHelpOverlay, } from "../hotkeys"; import { sync } from "../devices/actions"; -import { unselectPlant } from "../farm_designer/map/actions"; import { save } from "../api/crud"; import { Actions } from "../constants"; import { Path } from "../internal_urls"; +import { mockDispatch } from "../__test_support__/fake_dispatch"; describe("hotkeysWithActions()", () => { beforeEach(() => { @@ -59,14 +58,12 @@ describe("hotkeysWithActions()", () => { hotkeys[HotKey.addEvent].onKeyDown?.(e); expect(navigate).toHaveBeenCalledWith(Path.farmEvents("add")); - hotkeysSettingsPath[HotKey.backToPlantOverview].onKeyDown?.(e); - expect(navigate).toHaveBeenCalledWith(Path.plants()); - expect(unselectPlant).toHaveBeenCalled(); - jest.clearAllMocks(); - const hotkeysPhotosPath = hotkeysWithActions(navigate, dispatch, "photos"); - hotkeysPhotosPath[HotKey.backToPlantOverview].onKeyDown?.(e); - expect(navigate).not.toHaveBeenCalled(); - expect(unselectPlant).not.toHaveBeenCalled(); + const hotkeysWithDispatch = + hotkeysWithActions(navigate, mockDispatch(dispatch), ""); + hotkeysWithDispatch[HotKey.closePanel].onKeyDown?.(e); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: false, + }); }); }); diff --git a/frontend/css/farm_designer/farm_designer_panels.scss b/frontend/css/farm_designer/farm_designer_panels.scss index 05b07cb3dc..ac2839a854 100644 --- a/frontend/css/farm_designer/farm_designer_panels.scss +++ b/frontend/css/farm_designer/farm_designer_panels.scss @@ -362,16 +362,6 @@ } } -.additional-weed-properties { - li { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - align-items: center; - margin-top: 1rem; - } -} - .panel-section { .delete { height: 2rem; diff --git a/frontend/farm_designer/__tests__/map_size_setting_test.tsx b/frontend/farm_designer/__tests__/map_size_setting_test.tsx index afc3a1f8ba..8f8d9f74e2 100644 --- a/frontend/farm_designer/__tests__/map_size_setting_test.tsx +++ b/frontend/farm_designer/__tests__/map_size_setting_test.tsx @@ -5,21 +5,27 @@ jest.mock("../../config_storage/actions", () => ({ import React from "react"; import { MapSizeInputs, MapSizeInputsProps } from "../map_size_setting"; -import { mount } from "enzyme"; +import { render, screen } from "@testing-library/react"; import { setWebAppConfigValue } from "../../config_storage/actions"; import { NumericSetting } from "../../session_keys"; +import { fakeWebAppConfig } from "../../__test_support__/fake_state/resources"; +import { WebAppConfig } from "farmbot/dist/resources/configs/web_app"; +import { changeBlurableInputRTL } from "../../__test_support__/helpers"; describe("", () => { - const fakeProps = (): MapSizeInputsProps => ({ - getConfigValue: () => 100, - dispatch: jest.fn(), - }); + const fakeProps = (config: WebAppConfig): MapSizeInputsProps => { + return { + getConfigValue: key => config[key], + dispatch: jest.fn(), + }; + }; it("changes value", () => { - const wrapper = mount(); - wrapper.find("input").last().simulate("change"), { - currentTarget: { value: 100 } - }; + const config = fakeWebAppConfig(); + const p = fakeProps(config.body); + render(); + const input = screen.getByDisplayValue("" + config.body.map_size_y); + changeBlurableInputRTL(input, "100"); expect(setWebAppConfigValue).toHaveBeenCalledWith( NumericSetting.map_size_y, "100"); }); diff --git a/frontend/farm_designer/map_size_setting.tsx b/frontend/farm_designer/map_size_setting.tsx index 3b107f52d9..8623dbe7d5 100644 --- a/frontend/farm_designer/map_size_setting.tsx +++ b/frontend/farm_designer/map_size_setting.tsx @@ -3,7 +3,7 @@ import { GetWebAppConfigValue, setWebAppConfigValue, } from "../config_storage/actions"; import { t } from "../i18next_wrapper"; -import { Row } from "../ui"; +import { BlurableInput, Row } from "../ui"; import { NumericSetting } from "../session_keys"; import { NumberConfigKey as WebAppNumberConfigKey, @@ -20,12 +20,12 @@ interface LengthInputProps { const LengthInput = (props: LengthInputProps) => - props.dispatch(setWebAppConfigValue( + onCommit={e => props.dispatch(setWebAppConfigValue( props.setting, e.currentTarget.value))} /> ; diff --git a/frontend/hotkeys.tsx b/frontend/hotkeys.tsx index 25b4abdff9..55382f130f 100644 --- a/frontend/hotkeys.tsx +++ b/frontend/hotkeys.tsx @@ -2,8 +2,9 @@ import React from "react"; import { getLinks } from "./nav/nav_links"; import { sync } from "./devices/actions"; import { HotkeyConfig, useHotkeys, HotkeysDialog2 } from "@blueprintjs/core"; -import { unselectPlant } from "./farm_designer/map/actions"; -import { getPanelPath, PANEL_BY_SLUG } from "./farm_designer/panel_header"; +import { + getPanelPath, PANEL_BY_SLUG, setPanelOpen, +} from "./farm_designer/panel_header"; import { t } from "./i18next_wrapper"; import { store } from "./redux/store"; import { save } from "./api/crud"; @@ -25,7 +26,7 @@ export enum HotKey { navigateLeft = "navigateLeft", addPlant = "addPlant", addEvent = "addEvent", - backToPlantOverview = "backToPlantOverview", + closePanel = "closePanel", openGuide = "openGuide", } @@ -54,9 +55,9 @@ const HOTKEY_BASE_MAP = (): HotkeyConfigs => ({ combo: "ctrl + shift + e", label: t("Add Event"), }, - [HotKey.backToPlantOverview]: { + [HotKey.closePanel]: { combo: "escape", - label: t("Back to plant overview"), + label: t("Close panel"), }, [HotKey.openGuide]: { combo: "shift + ?", @@ -106,14 +107,9 @@ export const hotkeysWithActions = ( ...hotkeysBase[HotKey.addEvent], onKeyDown: () => { navigate(Path.farmEvents("add")); }, }, - [HotKey.backToPlantOverview]: { - ...hotkeysBase[HotKey.backToPlantOverview], - onKeyDown: () => { - if (slug != "photos") { - navigate(Path.plants()); - dispatch(unselectPlant(dispatch)); - } - }, + [HotKey.closePanel]: { + ...hotkeysBase[HotKey.closePanel], + onKeyDown: () => { dispatch(setPanelOpen(false)); }, }, [HotKey.openGuide]: hotkeysBase[HotKey.openGuide], }; diff --git a/frontend/nav/__tests__/index_test.tsx b/frontend/nav/__tests__/index_test.tsx index 3d4c6eadd0..90e158bca8 100644 --- a/frontend/nav/__tests__/index_test.tsx +++ b/frontend/nav/__tests__/index_test.tsx @@ -39,6 +39,7 @@ import { import { app } from "../../__test_support__/fake_state/app"; import { Actions } from "../../constants"; import { cloneDeep } from "lodash"; +import { mountWithContext } from "../../__test_support__/mount_with_context"; describe("", () => { const fakeProps = (): NavBarProps => ({ @@ -166,7 +167,7 @@ describe("", () => { }); it("displays setup button", () => { - const wrapper = mount(); + const wrapper = mountWithContext(); wrapper.find(".setup-button").simulate("click"); expect(mockNavigate).toHaveBeenCalledWith(Path.setup()); expect(wrapper.text().toLowerCase()).toContain("complete"); diff --git a/frontend/nav/index.tsx b/frontend/nav/index.tsx index 0363d454cc..9fb413dd9e 100644 --- a/frontend/nav/index.tsx +++ b/frontend/nav/index.tsx @@ -2,7 +2,6 @@ import React from "react"; import { NavBarProps, NavBarState } from "./interfaces"; import { EStopButton } from "./e_stop_btn"; import { Popover } from "../ui"; -import { useNavigate } from "react-router"; import { updatePageInfo } from "../util"; import { validBotLocationData } from "../util/location"; import { NavLinks } from "./nav_links"; @@ -34,6 +33,8 @@ import { PopupsState } from "../interfaces"; import { Panel, TAB_ICON } from "../farm_designer/panel_header"; import { movementPercentRemaining } from "../farm_designer/move_to"; import { isMobile } from "../screen_size"; +import { NavigationContext } from "../routes_helpers"; +import { NavigateFunction } from "react-router"; export class NavBar extends React.Component> { state: NavBarState = { @@ -54,6 +55,10 @@ export class NavBar extends React.Component> { } }; + static contextType = NavigationContext; + context!: React.ContextType; + navigate: NavigateFunction = url => { this.context(url as string); }; + get isStaff() { return this.props.authAud == "staff"; } toggle = (key: keyof NavBarState) => () => @@ -191,10 +196,9 @@ export class NavBar extends React.Component> { SetupButton = () => { const firmwareHardware = this.props.apiFirmwareValue; const { wizardStepResults, device } = this.props; - const navigate = useNavigate(); return !device.body.setup_completed_at ? { navigate(Path.setup()); }}> + onClick={() => { this.navigate(Path.setup()); }}> {t("Setup")} {!isMobile() && `: ${setupProgressString(wizardStepResults, { firmwareHardware })}`} diff --git a/frontend/points/__tests__/point_edit_actions_test.tsx b/frontend/points/__tests__/point_edit_actions_test.tsx index 43bf9b4aeb..d629709661 100644 --- a/frontend/points/__tests__/point_edit_actions_test.tsx +++ b/frontend/points/__tests__/point_edit_actions_test.tsx @@ -15,10 +15,10 @@ import { EditPointRadius, EditPointRadiusProps, EditPointColor, EditPointColorProps, updatePoint, EditPointName, EditPointNameProps, - AdditionalWeedProperties, - AdditionalWeedPropertiesProps, EditPointSoilHeightTag, EditPointSoilHeightTagProps, + EditWeedProperties, + EditWeedPropertiesProps, } from "../point_edit_actions"; import { fakePoint, fakeWeed, @@ -140,26 +140,32 @@ describe("", () => { }); describe("", () => { - const fakeProps = (): AdditionalWeedPropertiesProps => ({ - point: fakeWeed(), + const fakeProps = (): EditWeedPropertiesProps => ({ + weed: fakeWeed(), updatePoint: jest.fn(), + botOnline: true, + dispatch: jest.fn(), + defaultAxes: "XY", + arduinoBusy: false, + currentBotLocation: { x: 10, y: 20, z: 30 }, + movementState: fakeMovementState(), }); it("renders unknown source", () => { const p = fakeProps(); - p.point.body.meta = { + p.weed.body.meta = { meta_key: "meta value", created_by: undefined, key: undefined, color: "red", type: "weed", }; - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.text()).toContain("unknown"); expect(wrapper.text()).toContain("meta value"); }); it("changes method", () => { const p = fakeProps(); - p.point.body.meta = { removal_method: "automatic" }; - const wrapper = shallow(); + p.weed.body.meta = { removal_method: "automatic" }; + const wrapper = shallow(); wrapper.find("input").last().simulate("change"); expect(p.updatePoint).toHaveBeenCalledWith({ meta: { removal_method: "manual" } diff --git a/frontend/points/point_edit_actions.tsx b/frontend/points/point_edit_actions.tsx index 87c8b9f3d6..f247faf516 100644 --- a/frontend/points/point_edit_actions.tsx +++ b/frontend/points/point_edit_actions.tsx @@ -31,20 +31,12 @@ export const updatePoint = } }; -interface EditPointPropertiesProps { - point: TaggedGenericPointer | TaggedWeedPointer; - updatePoint(update: PointUpdate): void; - botOnline: boolean; - defaultAxes: string; - arduinoBusy: boolean; - dispatch: Function; - currentBotLocation: BotPosition; - movementState: MovementState; +interface EditPointPropertiesProps extends EditPointLocationBaseProps { + point: TaggedGenericPointer; } -export interface AdditionalWeedPropertiesProps { - point: TaggedWeedPointer; - updatePoint(update: PointUpdate): void; +export interface EditWeedPropertiesProps extends EditPointLocationBaseProps { + weed: TaggedWeedPointer; } export const EditPointProperties = (props: EditPointPropertiesProps) => @@ -64,35 +56,72 @@ export const EditPointProperties = (props: EditPointPropertiesProps) => defaultAxes={props.defaultAxes} updatePoint={props.updatePoint} /> - {props.point.body.pointer_type == "GenericPointer" && - - - } + + + + + + + {Object.entries(props.point.body.meta).map(([key, value]) => { + switch (key) { + case "color": + case "at_soil_level": + case "removal_method": + case "type": + case "gridId": + return
; + case "created_by": + return + {lookupPointSource(value)} + ; + default: + return + {value || ""} + ; + } + })} ; -export const AdditionalWeedProperties = (props: AdditionalWeedPropertiesProps) => -
    +export const EditWeedProperties = (props: EditWeedPropertiesProps) => +
      + + +
      - +
      - {daysOldText(plantAgeAndStage(props.point))} + {daysOldText(plantAgeAndStage(props.weed))}
      - {Object.entries(props.point.body.meta).map(([key, value]) => { + {Object.entries(props.weed.body.meta).map(([key, value]) => { switch (key) { case "color": - case "type": return
      ; + case "type": return
      ; case "created_by": return {lookupPointSource(value)} @@ -105,7 +134,7 @@ export const AdditionalWeedProperties = (props: AdditionalWeedPropertiesProps) = { - const newMeta = cloneDeep(props.point.body.meta); + const newMeta = cloneDeep(props.weed.body.meta); newMeta.removal_method = method; props.updatePoint({ meta: newMeta }); }} /> @@ -147,9 +176,8 @@ export const EditPointName = (props: EditPointNameProps) => onCommit={e => props.updatePoint({ name: e.currentTarget.value })} />
      ; -export interface EditPointLocationProps { +interface EditPointLocationBaseProps { updatePoint(update: PointUpdate): void; - pointLocation: Record; botOnline: boolean; defaultAxes: string; arduinoBusy: boolean; @@ -158,6 +186,10 @@ export interface EditPointLocationProps { movementState: MovementState; } +export interface EditPointLocationProps extends EditPointLocationBaseProps { + pointLocation: Record; +} + export const EditPointLocation = (props: EditPointLocationProps) => {["x", "y", "z"].map((axis: Xyz) => diff --git a/frontend/points/point_info.tsx b/frontend/points/point_info.tsx index 43d38ef9ae..73a709bf35 100644 --- a/frontend/points/point_info.tsx +++ b/frontend/points/point_info.tsx @@ -10,10 +10,7 @@ import { Everything, MovementState, ResourceColor } from "../interfaces"; import { TaggedGenericPointer } from "farmbot"; import { maybeFindGenericPointerById } from "../resources/selectors"; import { Actions } from "../constants"; -import { - EditPointProperties, updatePoint, lookupPointSource, -} from "./point_edit_actions"; -import { ListItem } from "../plants/plant_panel"; +import { EditPointProperties, updatePoint } from "./point_edit_actions"; import { isBotOnlineFromState } from "../devices/must_be_online"; import { destroy, save } from "../api/crud"; import { Path } from "../internal_urls"; @@ -97,37 +94,14 @@ export const RawEditPoint = (props: EditPointProps) => { {point - ?
      - -
        - {Object.entries(point.body.meta).map(([key, value]) => { - switch (key) { - case "color": - case "at_soil_level": - case "removal_method": - case "type": - case "gridId": - return
        ; - case "created_by": - return - {lookupPointSource(value)} - ; - default: - return - {value || ""} - ; - } - })} -
      -
      + ? : {t("Redirecting")}...}
      ; diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index b001f4747c..8453fe0f0b 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -9,6 +9,7 @@ import { } from "../three_d_garden/config_overlays"; import { ASSETS } from "../three_d_garden/constants"; import { getFocusFromUrlParams } from "../three_d_garden/zoom_beacons_constants"; +import { MemoryRouter } from "react-router"; export const Promo = () => { const [config, setConfig] = React.useState(INITIAL); @@ -28,9 +29,11 @@ export const Promo = () => { return
      - - - + + + + + {!config.config && setConfig({ ...config, config: true })} />} diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 8784b9dd97..2bc7543fa1 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -160,7 +160,7 @@ export const Bed = (props: BedProps) => { const Soil = ({ children, addPlantProps }: SoilProps) => { const soilDepth = bedHeight + zZero(props.config) - soilHeight; return { + onClick={(e: ThreeEvent) => { e.stopPropagation(); if (addPlantProps && getMode() == Mode.clickToAdd) { dropPlant({ @@ -174,7 +174,7 @@ export const Bed = (props: BedProps) => { }); } }} - onPointerMove={e => { + onPointerMove={(e: ThreeEvent) => { if (addPlantProps && getMode() == Mode.clickToAdd && !isMobile() diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 84f104e9bd..18145f10e0 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -21,11 +21,10 @@ import { VacuumPumpCover, VacuumPumpCoverFull, } from "./parts"; import { PowerSupply } from "./power_supply"; -import { XAxisWaterTube } from "./x_axis_water_tube"; import { Group, Mesh, MeshPhongMaterial } from "../components"; -import { Tools } from "./components/tools"; -import { ElectronicsBox } from "./components/electronics_box"; -import { Bounds } from "./components/bounds"; +import { + ElectronicsBox, Bounds, Tools, Solenoid, XAxisWaterTube, +} from "./components"; import { SlotWithTool } from "../../resources/interfaces"; const extrusionWidth = 20; @@ -78,11 +77,6 @@ type CameraMountHalf = GLTF & { nodes: { [PartName.cameraMountHalf]: THREE.Mesh }; materials: never; } - -type Solenoid = GLTF & { - nodes: { [PartName.solenoid]: THREE.Mesh }; - materials: { PaletteMaterial001: THREE.MeshStandardMaterial }; -} type XAxisCCMount = GLTF & { nodes: { [PartName.xAxisCCMount]: THREE.Mesh }; materials: never; @@ -124,6 +118,7 @@ export interface FarmbotModelProps { activeFocus: string; toolSlots?: SlotWithTool[]; mountedToolName?: string | undefined; + dispatch?: Function; } export const Bot = (props: FarmbotModelProps) => { @@ -159,7 +154,6 @@ export const Bot = (props: FarmbotModelProps) => { const VacuumPumpCoverComponent = VacuumPumpCover(vacuumPumpCover); const cameraMountHalf = useGLTF( ASSETS.models.cameraMountHalf, LIB_DIR) as CameraMountHalf; - const solenoid = useGLTF(ASSETS.models.solenoid, LIB_DIR) as Solenoid; const xAxisCCMount = useGLTF(ASSETS.models.xAxisCCMount, LIB_DIR) as XAxisCCMount; const [trackShape, setTrackShape] = useState(); const [beamShape, setBeamShape] = useState(); @@ -807,18 +801,10 @@ export const Bot = (props: FarmbotModelProps) => { geometry={beltClip.nodes[PartName.beltClip].geometry}> - + diff --git a/frontend/three_d_garden/bot/components/__tests__/solenoid_test.tsx b/frontend/three_d_garden/bot/components/__tests__/solenoid_test.tsx new file mode 100644 index 0000000000..3ff5d8fe90 --- /dev/null +++ b/frontend/three_d_garden/bot/components/__tests__/solenoid_test.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { INITIAL } from "../../../config"; +import { clone } from "lodash"; +import { Solenoid, SolenoidProps } from "../solenoid"; + +describe("", () => { + const fakeProps = (): SolenoidProps => ({ + config: clone(INITIAL), + }); + + it("renders solenoid", () => { + const { container } = render(); + expect(container).toContainHTML("solenoid"); + }); +}); diff --git a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx index 21dbf78f05..448a64a2fb 100644 --- a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx @@ -25,8 +25,12 @@ jest.mock("react", () => { }; }); +jest.mock("../watering_animations", () => ({ + WateringAnimations: jest.fn(), +})); + import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { INITIAL } from "../../../config"; import { clone } from "lodash"; import { Tools, ToolsProps } from "../tools"; @@ -34,6 +38,10 @@ import { fakeTool, fakeToolSlot, } from "../../../../__test_support__/fake_state/resources"; import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources"; +import { WateringAnimations } from "../watering_animations"; +import { Path } from "../../../../internal_urls"; +import { Actions } from "../../../../constants"; +import { mockDispatch } from "../../../../__test_support__/fake_dispatch"; describe("", () => { const fakeProps = (): ToolsProps => ({ @@ -97,4 +105,74 @@ describe("", () => { const { container } = render(); expect(container).not.toContainHTML("toolbay3"); }); + + it("renders watering animations when not in toolbay and water flowing", () => { + const p = fakeProps(); + p.config.waterFlow = true; + const tool = fakeTool(); + tool.body.name = "watering nozzle"; + p.toolSlots = []; + p.mountedToolName = "watering nozzle"; + render(); + expect(WateringAnimations).toHaveBeenCalled(); + }); + + it("doesn't render watering animations when water not flowing", () => { + const p = fakeProps(); + p.config.waterFlow = false; + const tool = fakeTool(); + tool.body.name = "watering nozzle"; + p.toolSlots = []; + p.mountedToolName = "watering nozzle"; + render(); + expect(WateringAnimations).not.toHaveBeenCalled(); + }); + + it("doesn't render watering animations when in toolbay", () => { + const p = fakeProps(); + p.config.waterFlow = true; + const tool = fakeTool(); + tool.body.name = "watering nozzle"; + const toolSlot = fakeToolSlot(); + toolSlot.body.tool_id = tool.body.id; + p.toolSlots = [{ toolSlot, tool }]; + render(); + expect(WateringAnimations).not.toHaveBeenCalled(); + }); + + it("navigates to tool info", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + const slot = container.querySelector("[name='slot'"); + slot && fireEvent.click(slot); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.toolSlots("1")); + }); + + it("doesn't navigate to tool info", () => { + const p = fakeProps(); + p.dispatch = undefined; + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + const slot = container.querySelector("[name='slot'"); + slot && fireEvent.click(slot); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/bot/components/__tests__/water_stream_test.tsx b/frontend/three_d_garden/bot/components/__tests__/water_stream_test.tsx new file mode 100644 index 0000000000..b3c0735ae8 --- /dev/null +++ b/frontend/three_d_garden/bot/components/__tests__/water_stream_test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { render, renderHook } from "@testing-library/react"; +import { + WaterStream, WaterStreamProps, useWaterFlowTexture, +} from "../water_stream"; + +let frameCallback: (state: unknown, delta: number) => void; +jest.mock("@react-three/fiber", () => ({ + useFrame: jest.fn((callback) => { + frameCallback = callback; + }), +})); + +describe("", () => { + const fakeProps = (): WaterStreamProps => ({ + name: "mock-water-stream", + args: [], + waterFlow: true, + }); + + it("renders", () => { + const wrapper = render(); + expect(wrapper.container).toContainHTML("mock-water-stream"); + }); +}); + +describe("useWaterFlowTexture", () => { + it("returns undefined texture when static", () => { + const { result } = renderHook(() => useWaterFlowTexture(false)); + expect(result.current).toBeUndefined(); + }); + + it("offsets texture when flowing", () => { + const { result } = renderHook(() => useWaterFlowTexture(true)); + const initialOffset = result.current!.offset.x; + const delta = 1; + frameCallback({}, delta); + expect(result.current!.offset.x).toBe(initialOffset - delta * 0.05); + }); +}); diff --git a/frontend/three_d_garden/bot/components/__tests__/water_tube_test.tsx b/frontend/three_d_garden/bot/components/__tests__/water_tube_test.tsx new file mode 100644 index 0000000000..03d70df4eb --- /dev/null +++ b/frontend/three_d_garden/bot/components/__tests__/water_tube_test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { WaterTube, WaterTubeProps } from "../water_tube"; +import { easyCubicBezierCurve3 } from "../../../helpers"; + +describe("", () => { + const fakeProps = (): WaterTubeProps => ({ + tubeName: "mock-tube", + tubePath: easyCubicBezierCurve3([0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]), + tubularSegments: 1, + radius: 1, + radialSegments: 1, + waterFlow: false, + }); + + it("renders", () => { + const p = fakeProps(); + const wrapper = render(); + expect(wrapper.container).toContainHTML("mock-tube-tube"); + expect(wrapper.container).toContainHTML("mock-tube-water-stream"); + }); +}); diff --git a/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx b/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx new file mode 100644 index 0000000000..ce607bcb82 --- /dev/null +++ b/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { + WateringAnimations, WateringAnimationsProps, +} from "../watering_animations"; + +describe("", () => { + const fakeProps = (): WateringAnimationsProps => ({ + waterFlow: true, + botPositionZ: 100, + soilHeight: 0, + }); + + it("renders", () => { + const p = fakeProps(); + p.botPositionZ = 200; + p.soilHeight = 50; + const { container } = render(); + const streams = container.querySelectorAll("[name*='water-stream']"); + expect(streams.length).toEqual(16); + + const clouds = container.querySelectorAll("[name*='waterfall-mist-cloud']"); + expect(clouds.length).toEqual(2); + }); +}); diff --git a/frontend/three_d_garden/bot/__tests__/x_axis_water_tube_test.tsx b/frontend/three_d_garden/bot/components/__tests__/x_axis_water_tube_test.tsx similarity index 55% rename from frontend/three_d_garden/bot/__tests__/x_axis_water_tube_test.tsx rename to frontend/three_d_garden/bot/components/__tests__/x_axis_water_tube_test.tsx index 95d8e064ca..8b62ef71e0 100644 --- a/frontend/three_d_garden/bot/__tests__/x_axis_water_tube_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/x_axis_water_tube_test.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { mount } from "enzyme"; +import { render } from "@testing-library/react"; import { XAxisWaterTubeProps, XAxisWaterTube } from "../x_axis_water_tube"; import { clone } from "lodash"; -import { INITIAL } from "../../config"; +import { INITIAL } from "../../../config"; describe("", () => { const fakeProps = (): XAxisWaterTubeProps => ({ @@ -10,7 +10,8 @@ describe("", () => { }); it("renders", () => { - const wrapper = mount(); - expect(wrapper.html()).toContain("x-axis-water-tube"); + const p = fakeProps(); + const { container } = render(); + expect(container).toContainHTML("x-axis-water-tube"); }); }); diff --git a/frontend/three_d_garden/bot/components/index.ts b/frontend/three_d_garden/bot/components/index.ts new file mode 100644 index 0000000000..91b83999ae --- /dev/null +++ b/frontend/three_d_garden/bot/components/index.ts @@ -0,0 +1,6 @@ +export * from "./bounds"; +export * from "./electronics_box"; +export * from "./solenoid"; +export * from "./tools"; +export * from "./water_tube"; +export * from "./x_axis_water_tube"; diff --git a/frontend/three_d_garden/bot/components/solenoid.tsx b/frontend/three_d_garden/bot/components/solenoid.tsx new file mode 100644 index 0000000000..695fab3b6a --- /dev/null +++ b/frontend/three_d_garden/bot/components/solenoid.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import * as THREE from "three"; +import { Config } from "../../config"; +import { Group, Mesh } from "../../components"; +import { WaterTube } from "./water_tube"; +import { easyCubicBezierCurve3, threeSpace } from "../../helpers"; +import { GLTF } from "three-stdlib"; +import { useGLTF } from "@react-three/drei"; +import { ASSETS, LIB_DIR, PartName } from "../../constants"; + +type SolenoidPart = GLTF & { + nodes: { [PartName.solenoid]: THREE.Mesh }; + materials: { PaletteMaterial001: THREE.MeshStandardMaterial }; +} + +export interface SolenoidProps { + config: Config; +} + +export const Solenoid = (props: SolenoidProps) => { + const { config } = props; + const { + x, y, z, bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, + columnLength, zGantryOffset, + } = config; + const solenoid = useGLTF(ASSETS.models.solenoid, LIB_DIR) as SolenoidPart; + return + + + + + + ; +}; diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index a0013430f5..47002fff1e 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -22,6 +22,10 @@ import { } from "../../../farm_designer/map/tool_graphics/all_tools"; import { Xyz } from "farmbot"; import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources"; +import { WateringAnimations } from "./watering_animations"; +import { useNavigate } from "react-router"; +import { Path } from "../../../internal_urls"; +import { setPanelOpen } from "../../../farm_designer/panel_header"; type Toolbay3 = GLTF & { nodes: { @@ -66,9 +70,11 @@ export interface ToolsProps { config: Config; toolSlots?: SlotWithTool[]; mountedToolName?: string | undefined; + dispatch?: Function; } interface ConvertedTools { + id?: number | undefined; x: number; y: number; z: number; @@ -84,6 +90,7 @@ export const convertSlotsWithTools = const toolName = reduceToolName(swt.tool?.body.name); if (toolName == ToolName.seedTrough) { troughIndex++; } return { + id: swt.toolSlot.body.id, x: swt.toolSlot.body.x, y: swt.toolSlot.body.y, z: swt.toolSlot.body.z, @@ -143,17 +150,26 @@ export const Tools = (props: ToolsProps) => { children?: React.ReactNode; toolPulloutDirection: ToolPulloutDirection; mounted: boolean; + id: number | undefined; + inToolbay: boolean; } const ToolbaySlot = (slotProps: ToolbaySlotProps) => { const { position, children, toolPulloutDirection, mounted } = slotProps; const rotationMultiplier = rotationFactor(toolPulloutDirection); - return + ]} + onClick={() => { + if (slotProps.id && !isUndefined(props.dispatch)) { + props.dispatch(setPanelOpen(true)); + navigate(Path.toolSlots(slotProps.id)); + } + }}> {rotationMultiplier && @@ -179,14 +195,16 @@ export const Tools = (props: ToolsProps) => { } const Tool = (toolProps: ToolProps) => { - const { toolPulloutDirection } = toolProps; - const mounted = toolProps.inToolbay && toolProps.toolName == mountedToolName; + const { toolPulloutDirection, inToolbay, id } = toolProps; + const mounted = inToolbay && toolProps.toolName == mountedToolName; const position = { x: threeSpace(toolProps.x, bedLengthOuter) + bedXOffset, y: threeSpace(toolProps.y, bedWidthOuter) + bedYOffset, - z: zZero - zDir * toolProps.z + (toolProps.inToolbay ? 0 : (utmHeight / 2 - 15)), + z: zZero - zDir * toolProps.z + (inToolbay ? 0 : (utmHeight / 2 - 15)), + }; + const common: ToolbaySlotProps = { + mounted, position, toolPulloutDirection, id, inToolbay, }; - const common = { mounted, position, toolPulloutDirection }; switch (toolProps.toolName) { case ToolName.rotaryTool: return @@ -203,14 +221,19 @@ export const Tools = (props: ToolsProps) => { return + {!inToolbay && props.config.waterFlow && + } ; case ToolName.seedBin: return diff --git a/frontend/three_d_garden/bot/components/water_stream.tsx b/frontend/three_d_garden/bot/components/water_stream.tsx new file mode 100644 index 0000000000..91a535d7f4 --- /dev/null +++ b/frontend/three_d_garden/bot/components/water_stream.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from "react"; +import { Tube } from "@react-three/drei"; +import { MeshPhongMaterial } from "../../components"; +import { TextureLoader, RepeatWrapping, Texture } from "three"; +import { useFrame } from "@react-three/fiber"; +import { ASSETS } from "../../constants"; + +const waterTexture = new TextureLoader().load(ASSETS.textures.water); +waterTexture.wrapS = waterTexture.wrapT = RepeatWrapping; + +export interface WaterStreamProps extends React.ComponentProps { + waterFlow: boolean; +} + +export const useWaterFlowTexture = (waterFlow: boolean): Texture | undefined => { + const texture = useMemo(() => waterFlow ? waterTexture : undefined, [waterFlow]); + + useFrame((_, delta) => { + if (waterFlow) { + waterTexture.offset.x -= delta * 0.05; + } + }); + + return texture; +}; + +export const WaterStream = (props: WaterStreamProps) => { + const { waterFlow } = props; + const waterTexture = useWaterFlowTexture(waterFlow); + + return + + ; +}; diff --git a/frontend/three_d_garden/bot/components/water_tube.tsx b/frontend/three_d_garden/bot/components/water_tube.tsx new file mode 100644 index 0000000000..a8f33023a4 --- /dev/null +++ b/frontend/three_d_garden/bot/components/water_tube.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Tube } from "@react-three/drei"; +import { MeshPhongMaterial, Group } from "../../components"; +import { WaterStream } from "./water_stream"; +import { Curve, Vector3 } from "three"; + +export interface WaterTubeProps { + tubeName: string; + tubePath: Curve; + tubularSegments: number; + radius: number; + radialSegments: number; + waterFlow: boolean; +} + +export const WaterTube = (props: WaterTubeProps) => { + const { + tubeName, tubePath, tubularSegments, radius, radialSegments, waterFlow, + } = props; + + return + + + + + ; +}; diff --git a/frontend/three_d_garden/bot/components/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx new file mode 100644 index 0000000000..765471f6df --- /dev/null +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { range } from "lodash"; +import { Group } from "../../components"; +import { ASSETS } from "../../constants"; +import { Cloud, Clouds } from "@react-three/drei"; +import { WaterStream } from "./water_stream"; +import { easyCubicBezierCurve3 } from "../../helpers"; + +export interface WateringAnimationsProps { + waterFlow: boolean; + botPositionZ: number; + soilHeight: number; +} + +export const WateringAnimations = (props: WateringAnimationsProps) => { + const { waterFlow, botPositionZ, soilHeight } = props; + const nozzleToSoil = botPositionZ - soilHeight; + + return + {range(16).map(i => { + const angle = (i * Math.PI * 2) / 16; + return ; + })} + + + + + + + ; +}; diff --git a/frontend/three_d_garden/bot/x_axis_water_tube.tsx b/frontend/three_d_garden/bot/components/x_axis_water_tube.tsx similarity index 71% rename from frontend/three_d_garden/bot/x_axis_water_tube.tsx rename to frontend/three_d_garden/bot/components/x_axis_water_tube.tsx index 551cfc1f0e..af5266b572 100644 --- a/frontend/three_d_garden/bot/x_axis_water_tube.tsx +++ b/frontend/three_d_garden/bot/components/x_axis_water_tube.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { Cylinder, Tube } from "@react-three/drei"; -import { Config } from "../config"; -import { threeSpace, easyCubicBezierCurve3 } from "../helpers"; -import { Group, MeshPhongMaterial } from "../components"; +import { Cylinder } from "@react-three/drei"; +import { Config } from "../../config"; +import { threeSpace, easyCubicBezierCurve3 } from "../../helpers"; +import { Group, MeshPhongMaterial } from "../../components"; +import { WaterTube } from "./water_tube"; export interface XAxisWaterTubeProps { config: Config; @@ -15,27 +16,23 @@ export const XAxisWaterTube = (props: XAxisWaterTubeProps) => { const barbY = threeSpace(-50, config.bedWidthOuter); const barbZ = groundZ + 20; const tubePath = easyCubicBezierCurve3( + [barbX, barbY, barbZ], + [-300, 0, 0], + [300, 0, 0], [ threeSpace(config.bedLengthOuter / 2 - 20, config.bedLengthOuter), threeSpace(-30, config.bedWidthOuter), -140, ], - [300, 0, 0], - [-300, 0, 0], - [barbX, barbY, barbZ], ); return - - - + = { @@ -336,6 +339,7 @@ export const PRESETS: Record = { zoomBeaconDebug: true, animate: true, distanceIndicator: "", + waterFlow: true, }, }; @@ -355,7 +359,7 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "threeAxes", "xyDimensions", "zDimension", "labelsOnHover", "promoInfo", "settingsBar", "zoomBeacons", "pan", "solar", "utilitiesPost", "packaging", "lab", "people", "scene", "lowDetail", "eventDebug", "cableDebug", "zoomBeaconDebug", - "animate", "distanceIndicator", "kitVersion", "negativeZ", + "animate", "distanceIndicator", "kitVersion", "negativeZ", "waterFlow", ]; export const modifyConfig = (config: Config, update: Partial) => { diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 3a801816b1..426f1c2b36 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -285,7 +285,7 @@ export const PrivateOverlay = (props: OverlayProps) => { options={["Standard", "Mobile"]} /> - + @@ -294,6 +294,7 @@ export const PrivateOverlay = (props: OverlayProps) => { "seeder", "None"]} /> + diff --git a/frontend/three_d_garden/constants.ts b/frontend/three_d_garden/constants.ts index 19eff71617..bb548a7b42 100644 --- a/frontend/three_d_garden/constants.ts +++ b/frontend/three_d_garden/constants.ts @@ -16,6 +16,7 @@ export const ASSETS: Record> = { concrete: "/3D/textures/concrete.avif", screen: "/3D/textures/screen.avif", bricks: "/3D/textures/bricks.avif", + water: "/3D/textures/water.avif", }, shapes: { track: "/3D/shapes/track.svg", diff --git a/frontend/three_d_garden/garden/__tests__/grid_test.tsx b/frontend/three_d_garden/garden/__tests__/grid_test.tsx index 986d9fc40b..d996372315 100644 --- a/frontend/three_d_garden/garden/__tests__/grid_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/grid_test.tsx @@ -1,9 +1,17 @@ import React from "react"; import { render } from "@testing-library/react"; -import { Grid, GridProps } from "../grid"; +import { Grid, gridLineOffsets, GridProps } from "../grid"; import { INITIAL } from "../../config"; import { clone } from "lodash"; +describe("gridLineOffsets()", () => { + it("calculates offsets", () => { + expect(gridLineOffsets(50)).toEqual([0, 50]); + expect(gridLineOffsets(200)).toEqual([0, 100, 200]); + expect(gridLineOffsets(510)).toEqual([0, 100, 200, 300, 400, 500, 510]); + }); +}); + describe("", () => { const fakeProps = (): GridProps => ({ config: clone(INITIAL), diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 7aed3aadb3..38a1c665ac 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { clone } from "lodash"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { INITIAL } from "../../config"; @@ -10,6 +10,9 @@ import { ThreeDPlantProps, } from "../plants"; import { CROPS } from "../../../crops/constants"; +import { Path } from "../../../internal_urls"; +import { Actions } from "../../../constants"; +import { mockDispatch } from "../../../__test_support__/fake_dispatch"; describe("calculatePlantPositions()", () => { it("calculates plant positions", () => { @@ -59,6 +62,7 @@ describe("convertPlants()", () => { expect(convertedPlants).toEqual([{ icon: CROPS.spinach.icon, + id: 1, label: "Spinach", size: 50, spread: 0, @@ -67,6 +71,7 @@ describe("convertPlants()", () => { }, { icon: CROPS["generic-plant"].icon, + id: 2, label: "Unknown", size: 50, spread: 0, @@ -118,4 +123,28 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("image"); }); + + it("navigates to plant info", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.plant.id = 1; + const { container } = render(); + const plant = container.querySelector("[name='0'"); + plant && fireEvent.click(plant); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); + }); + + it("doesn't navigate to plant info", () => { + const p = fakeProps(); + p.dispatch = undefined; + p.plant.id = 1; + const { container } = render(); + const plant = container.querySelector("[name='0'"); + plant && fireEvent.click(plant); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 84dd84b877..b145453945 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -1,9 +1,12 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { Point, PointProps } from "../point"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; +import { Path } from "../../../internal_urls"; +import { Actions } from "../../../constants"; +import { mockDispatch } from "../../../__test_support__/fake_dispatch"; describe("", () => { const fakeProps = (): PointProps => ({ @@ -15,4 +18,28 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("cylinder"); }); + + it("navigates to point info", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.point.body.id = 1; + const { container } = render(); + const point = container.querySelector("[name='marker'"); + point && fireEvent.click(point); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); + }); + + it("doesn't navigate to point info", () => { + const p = fakeProps(); + p.dispatch = undefined; + p.point.body.id = 1; + const { container } = render(); + const point = container.querySelector("[name='marker'"); + point && fireEvent.click(point); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index d202fd1529..c2effdea4a 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -1,9 +1,12 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { Weed, WeedProps } from "../weed"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakeWeed } from "../../../__test_support__/fake_state/resources"; +import { Path } from "../../../internal_urls"; +import { Actions } from "../../../constants"; +import { mockDispatch } from "../../../__test_support__/fake_dispatch"; describe("", () => { const fakeProps = (): WeedProps => ({ @@ -15,4 +18,28 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("weed"); }); + + it("navigates to weed info", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weed.body.id = 1; + const { container } = render(); + const weed = container.querySelector("[name='weed'"); + weed && fireEvent.click(weed); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + }); + + it("doesn't navigate to weed info", () => { + const p = fakeProps(); + p.dispatch = undefined; + p.weed.body.id = 1; + const { container } = render(); + const weed = container.querySelector("[name='weed'"); + weed && fireEvent.click(weed); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/garden/grid.tsx b/frontend/three_d_garden/garden/grid.tsx index 821382507b..98085f001a 100644 --- a/frontend/three_d_garden/garden/grid.tsx +++ b/frontend/three_d_garden/garden/grid.tsx @@ -3,7 +3,15 @@ import { Config } from "../config"; import { Group } from "../components"; import { Line } from "@react-three/drei"; import { zero as zeroFunc, extents as extentsFunc } from "../helpers"; -import { range } from "lodash"; +import { chain, floor, range } from "lodash"; + +export const gridLineOffsets = (botDimension: number): number[] => { + const lastRegularOffset = floor(botDimension, -2); + return chain(range(0, lastRegularOffset + 100, 100)) + .concat(botDimension) + .uniq() + .value(); +}; export interface GridProps { config: Config; @@ -15,19 +23,19 @@ export const Grid = (props: GridProps) => { const gridZ = zero.z - config.soilHeight + 5; const extents = extentsFunc(config); return - {range(0, config.botSizeX + 100, 100).map(x => - + )} - {range(0, config.botSizeY + 100, 100).map(y => - + )} ; }; diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index adcb35667d..bb18384e9f 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -7,9 +7,13 @@ import { Vector3 } from "three"; import { threeSpace, zZero as zZeroFunc } from "../helpers"; import { Text } from "../elements"; import { findIcon } from "../../crops/find"; -import { kebabCase } from "lodash"; +import { isUndefined, kebabCase } from "lodash"; +import { Path } from "../../internal_urls"; +import { useNavigate } from "react-router"; +import { setPanelOpen } from "../../farm_designer/panel_header"; interface Plant { + id?: number | undefined; label: string; icon: string; size: number; @@ -23,6 +27,7 @@ export interface ThreeDGardenPlant extends Plant { } export const convertPlants = (config: Config, plants: TaggedPlant[]): Plant[] => { return plants.map(plant => { return { + id: plant.body.id, label: plant.body.name, icon: findIcon(plant.body.openfarm_slug), size: plant.body.radius * 2, @@ -85,11 +90,13 @@ export interface ThreeDPlantProps { labelOnly?: boolean; config: Config; hoveredPlant: number | undefined; + dispatch?: Function; } export const ThreeDPlant = (props: ThreeDPlantProps) => { const { i, plant, labelOnly, config, hoveredPlant } = props; const alwaysShowLabels = config.labels && !config.labelsOnHover; + const navigate = useNavigate(); return { {plant.label} : { + if (plant.id && !isUndefined(props.dispatch)) { + props.dispatch(setPanelOpen(true)); + navigate(Path.plants(plant.id)); + } + }} transparent={true} renderOrder={1} />} ; diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index e22e5561fc..792506ea97 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -5,42 +5,56 @@ import { Group, MeshPhongMaterial } from "../components"; import { Cylinder, Sphere } from "@react-three/drei"; import { DoubleSide } from "three"; import { zero as zeroFunc, threeSpace } from "../helpers"; +import { useNavigate } from "react-router"; +import { Path } from "../../internal_urls"; +import { isUndefined } from "lodash"; +import { setPanelOpen } from "../../farm_designer/panel_header"; export interface PointProps { point: TaggedGenericPointer; config: Config; + dispatch?: Function; } export const Point = (props: PointProps) => { const { point, config } = props; const RADIUS = 25; const HEIGHT = 100; + const navigate = useNavigate(); return - - - - - - + { + if (point.body.id && !isUndefined(props.dispatch)) { + props.dispatch(setPanelOpen(true)); + navigate(Path.points(point.body.id)); + } + }}> + + + + + + + { const { weed, config } = props; + const navigate = useNavigate(); return { + if (weed.body.id && !isUndefined(props.dispatch)) { + props.dispatch(setPanelOpen(true)); + navigate(Path.weeds(weed.body.id)); + } + }} position={[ - threeSpace(weed.body.x, config.bedLengthOuter), - threeSpace(weed.body.y, config.bedWidthOuter), + threeSpace(weed.body.x, config.bedLengthOuter) + config.bedXOffset, + threeSpace(weed.body.y, config.bedWidthOuter) + config.bedYOffset, zeroFunc(config).z - config.soilHeight, ]}> { const { config } = props; + const dispatch = props.addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; const plants = isUndefined(props.addPlantProps) @@ -120,6 +121,7 @@ export const GardenModel = (props: GardenModelProps) => { {(!props.addPlantProps || !!props.addPlantProps.getConfigValue(BooleanSetting.show_farmbot)) && { )} + hoveredPlant={hoveredPlant} + dispatch={dispatch} />)} {props.mapPoints?.map(point => - )} + )} {props.weeds?.map(weed => - )} + )} diff --git a/frontend/ui/filter_search.tsx b/frontend/ui/filter_search.tsx index 528d08028f..65515fdcac 100644 --- a/frontend/ui/filter_search.tsx +++ b/frontend/ui/filter_search.tsx @@ -45,7 +45,7 @@ export class FilterSearch }}>