diff --git a/.circleci/config.yml b/.circleci/config.yml index 68ce3361d0..a719f31e0b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,7 +69,7 @@ commands: - run: name: Run JS tests command: | - sudo docker compose run web npm run test-slow -- -c .circleci/jest-ci.config.js + sudo docker compose run web npm run test-slow -- -c .circleci/jest-ci.config.js -w 6 echo 'export COVERAGE_AVAILABLE=true' >> $BASH_ENV lint-commands: steps: diff --git a/Gemfile.lock b/Gemfile.lock index 5a7745dc89..a9bd18e878 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,12 +141,12 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-storage_v1 (0.50.0) google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.7.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.2.1) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.4.0) + google-cloud-errors (1.5.0) google-cloud-storage (1.55.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -248,7 +248,7 @@ GEM hashie (~> 4.1) multi_json (~> 1.15) racc (1.8.1) - rack (2.2.11) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -376,7 +376,7 @@ GEM rails warden (1.2.9) rack (>= 2.0.9) - webmock (3.25.0) + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -439,4 +439,4 @@ RUBY VERSION ruby 3.3.7p123 BUNDLED WITH - 2.6.2 + 2.6.5 diff --git a/frontend/__test_support__/fake_designer_state.ts b/frontend/__test_support__/fake_designer_state.ts index d4953a1dbf..1ffae18e72 100644 --- a/frontend/__test_support__/fake_designer_state.ts +++ b/frontend/__test_support__/fake_designer_state.ts @@ -1,4 +1,4 @@ -import { DesignerState } from "../farm_designer/interfaces"; +import { DesignerState, DrawnPointPayl } from "../farm_designer/interfaces"; import { HelpState } from "../help/reducer"; import { RunButtonMenuOpen } from "../sequences/interfaces"; @@ -20,7 +20,6 @@ export const fakeDesignerState = (): DesignerState => ({ bulkPlantSlug: undefined, chosenLocation: { x: undefined, y: undefined, z: undefined }, drawnPoint: undefined, - drawnWeed: undefined, openedSavedGarden: undefined, tryGroupSortType: undefined, editGroupAreaInMap: false, @@ -63,3 +62,13 @@ export const fakeMenuOpenState = (): RunButtonMenuOpen => ({ component: undefined, uuid: undefined, }); + +export const fakeDrawnPoint = (): DrawnPointPayl => ({ + name: "Fake Point", + cx: 10, + cy: 20, + r: 30, + color: "green", + z: 0, + at_soil_level: false, +}); diff --git a/frontend/__tests__/hotkeys_test.tsx b/frontend/__tests__/hotkeys_test.tsx index 18b81c9ab3..1794c6f00d 100644 --- a/frontend/__tests__/hotkeys_test.tsx +++ b/frontend/__tests__/hotkeys_test.tsx @@ -12,58 +12,80 @@ jest.mock("../api/crud", () => ({ save: jest.fn() })); import React from "react"; import { shallow } from "enzyme"; import { - HotKey, HotKeys, HotKeysProps, hotkeysWithActions, toggleHotkeyHelpOverlay, + HotKey, HotKeys, HotKeysProps, hotkeysWithActions, HotkeysWithActionsProps, + toggleHotkeyHelpOverlay, } from "../hotkeys"; import { sync } from "../devices/actions"; import { save } from "../api/crud"; import { Actions } from "../constants"; import { Path } from "../internal_urls"; import { mockDispatch } from "../__test_support__/fake_dispatch"; +import { + fakeDesignerState, fakeDrawnPoint, +} from "../__test_support__/fake_designer_state"; +import { resetDrawnPointDataAction } from "../points/create_points"; describe("hotkeysWithActions()", () => { beforeEach(() => { location.pathname = Path.mock(Path.designer()); }); + const fakeProps = (): HotkeysWithActionsProps => ({ + navigate: jest.fn(), + dispatch: jest.fn(), + designer: fakeDesignerState(), + slug: "", + }); + it("has key bindings", () => { - const dispatch = jest.fn(); - const navigate = jest.fn(); - const hotkeys = hotkeysWithActions(navigate, dispatch, ""); + const p = fakeProps(); + const hotkeys = hotkeysWithActions(p); expect(Object.values(hotkeys).length).toBe(8); const e = {} as KeyboardEvent; hotkeys[HotKey.save].onKeyDown?.(e); expect(save).not.toHaveBeenCalled(); mockState.resources.consumers.sequences.current = "uuid"; - const hotkeysSettingsPath = hotkeysWithActions(navigate, dispatch, "settings"); + p.slug = "settings"; + const hotkeysSettingsPath = hotkeysWithActions(p); hotkeysSettingsPath[HotKey.save].onKeyDown?.(e); expect(save).not.toHaveBeenCalled(); - const hotkeysSequencesPath = hotkeysWithActions( - navigate, dispatch, "sequences"); + p.slug = "sequences"; + const hotkeysSequencesPath = hotkeysWithActions(p); hotkeysSequencesPath[HotKey.save].onKeyDown?.(e); expect(save).toHaveBeenCalledWith("uuid"); hotkeys[HotKey.sync].onKeyDown?.(e); - expect(dispatch).toHaveBeenCalledWith(sync()); + expect(p.dispatch).toHaveBeenCalledWith(sync()); hotkeys[HotKey.navigateRight].onKeyDown?.(e); - expect(navigate).toHaveBeenCalledWith(Path.plants()); + expect(p.navigate).toHaveBeenCalledWith(Path.plants()); hotkeys[HotKey.navigateLeft].onKeyDown?.(e); - expect(navigate).toHaveBeenCalledWith(Path.settings()); + expect(p.navigate).toHaveBeenCalledWith(Path.settings()); hotkeys[HotKey.addPlant].onKeyDown?.(e); - expect(navigate).toHaveBeenCalledWith(Path.cropSearch()); + expect(p.navigate).toHaveBeenCalledWith(Path.cropSearch()); hotkeys[HotKey.addEvent].onKeyDown?.(e); - expect(navigate).toHaveBeenCalledWith(Path.farmEvents("add")); + expect(p.navigate).toHaveBeenCalledWith(Path.farmEvents("add")); - const hotkeysWithDispatch = - hotkeysWithActions(navigate, mockDispatch(dispatch), ""); + p.slug = ""; + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const hotkeysWithDispatch = hotkeysWithActions(p); hotkeysWithDispatch[HotKey.closePanel].onKeyDown?.(e); expect(dispatch).toHaveBeenCalledWith({ type: Actions.SET_PANEL_OPEN, payload: false, }); + + p.dispatch = jest.fn(); + const point = fakeDrawnPoint(); + point.cx = 1; + p.designer.drawnPoint = point; + const hotkeysWithDrawnPoint = hotkeysWithActions(p); + hotkeysWithDrawnPoint[HotKey.closePanel].onKeyDown?.(e); + expect(p.dispatch).toHaveBeenCalledWith(resetDrawnPointDataAction()); }); }); @@ -81,6 +103,7 @@ describe("", () => { const fakeProps = (): HotKeysProps => ({ dispatch: jest.fn(), hotkeyGuide: false, + designer: fakeDesignerState(), }); it("renders", () => { diff --git a/frontend/app.tsx b/frontend/app.tsx index 3c198dfbe2..a4211f8f45 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -185,7 +185,9 @@ export class RawApp extends React.Component { {(Path.equals("") || Path.equals(Path.app())) && isString(landingPage) && } {!syncLoaded && } - + {syncLoaded && ; + navigate = this.context; + Realtime = () => { const { informational_settings } = this.props.bot.hardware; const { @@ -157,7 +162,7 @@ export class Connectivity - + {t("Learn more about ports")} diff --git a/frontend/devices/connectivity/diagnosis.tsx b/frontend/devices/connectivity/diagnosis.tsx index b86b6a0116..ecc9343aa2 100644 --- a/frontend/devices/connectivity/diagnosis.tsx +++ b/frontend/devices/connectivity/diagnosis.tsx @@ -69,11 +69,11 @@ export function Diagnosis(props: DiagnosisProps) {

{diagnosisMessage(getDiagnosisCode(props.statusFlags))}

- + {t("Click here to learn more about connectivity codes.")} - + {t("Click here for document to show to your IT department.")} diff --git a/frontend/devices/connectivity/qos_panel.tsx b/frontend/devices/connectivity/qos_panel.tsx index fd941147ef..a2012a2f51 100644 --- a/frontend/devices/connectivity/qos_panel.tsx +++ b/frontend/devices/connectivity/qos_panel.tsx @@ -7,6 +7,7 @@ import React from "react"; import { t } from "../../i18next_wrapper"; import { docLinkClick, Saucer } from "../../ui"; import { Actions } from "../../constants"; +import { NavigationContext } from "../../routes_helpers"; export interface QosPanelProps { pings: PingDictionary; @@ -63,6 +64,10 @@ export class QosPanel extends React.Component { return calculatePingLoss(this.pingState); } + static contextType = NavigationContext; + context!: React.ContextType; + navigate = this.context; + render() { const r = { ...this.latencyReport, ...this.qualityReport }; const errorRateDecimal = r.complete / r.total; @@ -82,7 +87,8 @@ export class QosPanel extends React.Component { - + {t("Learn more about connecting")} diff --git a/frontend/farm_designer/__tests__/location_info_test.tsx b/frontend/farm_designer/__tests__/location_info_test.tsx index f63b4794a7..3fcc06b03e 100644 --- a/frontend/farm_designer/__tests__/location_info_test.tsx +++ b/frontend/farm_designer/__tests__/location_info_test.tsx @@ -151,7 +151,10 @@ describe("", () => { wrapper.find(".add-point").simulate("click"); expect(p.dispatch).toHaveBeenCalledWith({ type: Actions.SET_DRAWN_POINT_DATA, - payload: { cx: 1, cy: 1 } + payload: { + name: "Location Point", cx: 1, cy: 1, color: "gray", r: 0, z: 0, + at_soil_level: false, + }, }); expect(mockNavigate).toHaveBeenCalledWith(Path.points("add")); }); diff --git a/frontend/farm_designer/__tests__/reducer_test.ts b/frontend/farm_designer/__tests__/reducer_test.ts index e4bc45b2a9..cdbf437c45 100644 --- a/frontend/farm_designer/__tests__/reducer_test.ts +++ b/frontend/farm_designer/__tests__/reducer_test.ts @@ -1,9 +1,11 @@ import { designer } from "../reducer"; import { Actions } from "../../constants"; import { ReduxAction } from "../../redux/interfaces"; -import { HoveredPlantPayl, DrawnPointPayl, DrawnWeedPayl } from "../interfaces"; +import { HoveredPlantPayl, DrawnPointPayl } from "../interfaces"; import { BotPosition } from "../../devices/interfaces"; -import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; +import { + fakeDesignerState, fakeDrawnPoint, +} from "../../__test_support__/fake_designer_state"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { PlantStage, PointType } from "farmbot"; import { UUID } from "../../resources/interfaces"; @@ -200,71 +202,10 @@ describe("designer reducer", () => { it("sets current point data", () => { const action: ReduxAction = { type: Actions.SET_DRAWN_POINT_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30, color: "red" } + payload: fakeDrawnPoint(), }; const newState = designer(oldState(), action); - expect(newState.drawnPoint).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "red" - }); - }); - - it("uses current point color", () => { - const action: ReduxAction = { - type: Actions.SET_DRAWN_POINT_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30 } - }; - const state = oldState(); - state.drawnPoint = { cx: 0, cy: 0, z: 0, r: 0, color: "red" }; - const newState = designer(state, action); - expect(newState.drawnPoint).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "red" - }); - }); - - it("uses default point color", () => { - const action: ReduxAction = { - type: Actions.SET_DRAWN_POINT_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30 } - }; - const newState = designer(oldState(), action); - expect(newState.drawnPoint).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "green" - }); - }); - - it("sets current weed data", () => { - const action: ReduxAction = { - type: Actions.SET_DRAWN_WEED_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30, color: "red" } - }; - const newState = designer(oldState(), action); - expect(newState.drawnWeed).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "red" - }); - }); - - it("uses current weed color", () => { - const action: ReduxAction = { - type: Actions.SET_DRAWN_WEED_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30 } - }; - const state = oldState(); - state.drawnWeed = { cx: 0, cy: 0, z: 0, r: 0, color: "red" }; - const newState = designer(state, action); - expect(newState.drawnWeed).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "red" - }); - }); - - it("uses default weed color", () => { - const action: ReduxAction = { - type: Actions.SET_DRAWN_WEED_DATA, - payload: { cx: 10, cy: 20, z: 0, r: 30 } - }; - const newState = designer(oldState(), action); - expect(newState.drawnWeed).toEqual({ - cx: 10, cy: 20, z: 0, r: 30, color: "red" - }); + expect(newState.drawnPoint).toEqual(fakeDrawnPoint()); }); it("sets opened saved garden", () => { diff --git a/frontend/farm_designer/interfaces.ts b/frontend/farm_designer/interfaces.ts index 433bf5aebc..dac57756b1 100644 --- a/frontend/farm_designer/interfaces.ts +++ b/frontend/farm_designer/interfaces.ts @@ -146,7 +146,6 @@ export interface DesignerState { bulkPlantSlug: string | undefined; chosenLocation: BotPosition; drawnPoint: DrawnPointPayl | undefined; - drawnWeed: DrawnWeedPayl | undefined; openedSavedGarden: number | undefined; tryGroupSortType: ExtendedPointGroupSortType | undefined; editGroupAreaInMap: boolean; @@ -359,20 +358,11 @@ export interface CameraCalibrationData { } export interface DrawnPointPayl { - name?: string; - cx: number; - cy: number; + name: string; + cx: number | undefined; + cy: number | undefined; z: number; r: number; - color?: string; - at_soil_level?: boolean; -} - -export interface DrawnWeedPayl { - name?: string; - cx: number; - cy: number; - z: number; - r: number; - color?: string; + color: string; + at_soil_level: boolean; } diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index f0e1534124..b550716af9 100644 --- a/frontend/farm_designer/location_info.tsx +++ b/frontend/farm_designer/location_info.tsx @@ -45,6 +45,7 @@ import { ImageFlipper } from "../photos/images/image_flipper"; import { PhotoFooter } from "../photos/images/photos"; import { Path } from "../internal_urls"; import { NavigationContext } from "../routes_helpers"; +import { DrawnPointPayl } from "./interfaces"; export const mapStateToProps = (props: Everything): LocationInfoProps => ({ chosenLocation: props.resources.consumers.farm_designer.chosenLocation, @@ -482,13 +483,16 @@ const LocationActions = (props: LocationActionsProps) => {  {props.currentBotLocation.z})

} - + {panelType == Panel.Points &&
} {panelType == Panel.Points && }
; diff --git a/frontend/regimens/editor/editor.tsx b/frontend/regimens/editor/editor.tsx index fccb454f9a..149a379292 100644 --- a/frontend/regimens/editor/editor.tsx +++ b/frontend/regimens/editor/editor.tsx @@ -21,6 +21,7 @@ import { Path } from "../../internal_urls"; import { addRegimen } from "../list/add_regimen"; import { selectAllRegimens } from "../../resources/selectors_by_kind"; import { RegimenButtonGroup } from "./regimen_edit_components"; +import { NavigationContext } from "../../routes_helpers"; export class RawDesignerRegimenEditor extends React.Component { @@ -29,6 +30,10 @@ export class RawDesignerRegimenEditor if (!this.props.current) { setActiveRegimenByName(); } } + static contextType = NavigationContext; + context!: React.ContextType; + navigate = this.context; + render() { const panelName = "designer-regimen-editor"; const regimen = this.props.current; @@ -49,7 +54,8 @@ export class RawDesignerRegimenEditor {!regimen && } diff --git a/frontend/regimens/list/__tests__/add_regimen_test.ts b/frontend/regimens/list/__tests__/add_regimen_test.ts index ffd19bf61a..3f541fe5e6 100644 --- a/frontend/regimens/list/__tests__/add_regimen_test.ts +++ b/frontend/regimens/list/__tests__/add_regimen_test.ts @@ -10,14 +10,15 @@ import { Path } from "../../../internal_urls"; describe("addRegimen()", () => { it("dispatches a new regimen onclick", () => { const dispatch = jest.fn(); - addRegimen(0)(dispatch); + const navigate = jest.fn(); + addRegimen(0, navigate)(dispatch); expect(dispatch).toHaveBeenCalledWith({ type: Actions.INIT_RESOURCE, payload: expect.objectContaining({ kind: "Regimen" }) }); - expect(mockNavigate).toHaveBeenCalledWith(Path.regimens("New_Regimen_0")); + expect(navigate).toHaveBeenCalledWith(Path.regimens("New_Regimen_0")); expect(setActiveRegimenByName).toHaveBeenCalled(); }); }); diff --git a/frontend/regimens/list/__tests__/list_test.tsx b/frontend/regimens/list/__tests__/list_test.tsx index 149bbb1e56..5eb8faf530 100644 --- a/frontend/regimens/list/__tests__/list_test.tsx +++ b/frontend/regimens/list/__tests__/list_test.tsx @@ -66,7 +66,7 @@ describe("", () => { p.regimens = [fakeRegimen(), fakeRegimen()]; const wrapper = shallow(); wrapper.find(DesignerPanelTop).simulate("click"); - expect(addRegimen).toHaveBeenCalledWith(2); + expect(addRegimen).toHaveBeenCalledWith(2, {}); }); }); diff --git a/frontend/regimens/list/add_regimen.ts b/frontend/regimens/list/add_regimen.ts index 11f44d48d4..a3bab142b4 100644 --- a/frontend/regimens/list/add_regimen.ts +++ b/frontend/regimens/list/add_regimen.ts @@ -1,10 +1,10 @@ -import { useNavigate } from "react-router"; import { TaggedRegimen } from "farmbot"; import { init } from "../../api/crud"; import { setActiveRegimenByName } from "../set_active_regimen_by_name"; import { urlFriendly } from "../../util"; import { t } from "../../i18next_wrapper"; import { Path } from "../../internal_urls"; +import { NavigateFunction } from "react-router"; const emptyRegimenBody = (regimenCount: number): TaggedRegimen["body"] => ({ name: (t("New Regimen ") + (regimenCount++)), @@ -13,10 +13,10 @@ const emptyRegimenBody = (regimenCount: number): TaggedRegimen["body"] => ({ body: [], }); -export const addRegimen = (regimenCount: number) => (dispatch: Function) => { - const newRegimen = emptyRegimenBody(regimenCount); - dispatch(init("Regimen", newRegimen)); - const navigate = useNavigate(); - navigate(Path.regimens(urlFriendly(newRegimen.name))); - setActiveRegimenByName(); -}; +export const addRegimen = (regimenCount: number, navigate: NavigateFunction) => + (dispatch: Function) => { + const newRegimen = emptyRegimenBody(regimenCount); + dispatch(init("Regimen", newRegimen)); + navigate(Path.regimens(urlFriendly(newRegimen.name))); + setActiveRegimenByName(); + }; diff --git a/frontend/regimens/list/list.tsx b/frontend/regimens/list/list.tsx index 37e1d3e532..6b469501ed 100644 --- a/frontend/regimens/list/list.tsx +++ b/frontend/regimens/list/list.tsx @@ -15,6 +15,7 @@ import { RegimenListItem } from "../list/regimen_list_item"; import { Everything } from "../../interfaces"; import { selectAllRegimens } from "../../resources/selectors"; import { resourceUsageList } from "../../resources/in_use"; +import { NavigationContext } from "../../routes_helpers"; export const mapStateToProps = (props: Everything): RegimensListProps => ({ dispatch: props.dispatch, @@ -26,13 +27,17 @@ export class RawDesignerRegimenList extends React.Component { state: RegimensListState = { searchTerm: "" }; + static contextType = NavigationContext; + context!: React.ContextType; + navigate = this.context; + render() { const panelName = "designer-regimen-list"; return - this.props.dispatch(addRegimen(this.props.regimens.length))} + onClick={() => this.props.dispatch( + addRegimen(this.props.regimens.length, this.navigate))} title={t("add new regimen")}> ) => { const [monaco, setMonaco] = React.useState(!isMobile()); @@ -20,11 +21,12 @@ export const TileAssertion = (props: StepParams) => { [StateToggleKey.luaExpanded]: { enabled: expanded, toggle: () => setExpanded(!expanded) }, }; + const navigate = useNavigate(); return + {" " + t("Documentation")} ]} diff --git a/frontend/sequences/step_tiles/tile_lua.tsx b/frontend/sequences/step_tiles/tile_lua.tsx index 5207463155..d6d5101a62 100644 --- a/frontend/sequences/step_tiles/tile_lua.tsx +++ b/frontend/sequences/step_tiles/tile_lua.tsx @@ -7,6 +7,7 @@ import { Lua } from "farmbot/dist/corpus"; import { ToolTips } from "../../constants"; import { LuaTextArea } from "./tile_lua_support"; import { isMobile } from "../../screen_size"; +import { useNavigate } from "react-router"; export const TileLua = (props: StepParams) => { const [monaco, setMonaco] = React.useState(!isMobile()); @@ -17,11 +18,12 @@ export const TileLua = (props: StepParams) => { [StateToggleKey.luaExpanded]: { enabled: expanded, toggle: () => setExpanded(!expanded) }, }; + const navigate = useNavigate(); return + {" " + t("Documentation")} ]} diff --git a/frontend/settings/custom_settings.tsx b/frontend/settings/custom_settings.tsx index 15bbf99f68..ebed8620a0 100644 --- a/frontend/settings/custom_settings.tsx +++ b/frontend/settings/custom_settings.tsx @@ -7,9 +7,11 @@ import { Collapse } from "@blueprintjs/core"; import { t } from "../i18next_wrapper"; import { EnvEditor } from "../photos/data_management/env_editor"; import { devDocLinkClick } from "../ui"; +import { useNavigate } from "react-router"; -export const CustomSettings = (props: CustomSettingsProps) => - { + const navigate = useNavigate(); + return
{" "} {t("Refer to the")} {" "} - + {t("developer documentation")} {" "} @@ -36,3 +38,4 @@ export const CustomSettings = (props: CustomSettingsProps) => farmwareEnvs={props.farmwareEnvs} /> ; +}; diff --git a/frontend/settings/fbos_settings/factory_reset_row.tsx b/frontend/settings/fbos_settings/factory_reset_row.tsx index 0348750878..437b3ce21b 100644 --- a/frontend/settings/fbos_settings/factory_reset_row.tsx +++ b/frontend/settings/fbos_settings/factory_reset_row.tsx @@ -5,9 +5,11 @@ import { softReset } from "../../devices/actions"; import { FactoryResetRowsProps } from "./interfaces"; import { t } from "../../i18next_wrapper"; import { Highlight } from "../maybe_highlight"; +import { useNavigate } from "react-router"; export const FactoryResetRows = (props: FactoryResetRowsProps) => { const { botOnline } = props; + const navigate = useNavigate(); return
@@ -38,7 +40,7 @@ export const FactoryResetRows = (props: FactoryResetRowsProps) => { ${t(Content.OS_RESET_WARNING, { resetMethod: t("Hard") })}`} />
+ onClick={docLinkClick("farmbot-os", navigate)}> {t("HARD RESET")} diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 89697546d4..3f3cfe8245 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -15,6 +15,8 @@ import { } from "../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../__test_support__/fake_props"; import { ASSETS } from "../constants"; +import { Path } from "../../internal_urls"; +import { fakeDrawnPoint } from "../../__test_support__/fake_designer_state"; describe("", () => { const fakeProps = (): GardenModelProps => ({ @@ -58,6 +60,16 @@ describe("", () => { expect(container).toContainHTML(ASSETS.other.weed); }); + it("renders drawn point", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const addPlantProps = fakeAddPlantProps([]); + addPlantProps.designer.drawnPoint = fakeDrawnPoint(); + p.addPlantProps = addPlantProps; + const { container } = render(); + expect(container).toContainHTML("drawn-point"); + }); + it("doesn't render bot", () => { const p = fakeProps(); p.addPlantProps = fakeAddPlantProps([]); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 3fc3b06494..d5ee387032 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -8,16 +8,24 @@ jest.mock("../../../screen_size", () => ({ })); const mockSetPosition = jest.fn(); -interface MockRefCurrent { +const mockSetScale = jest.fn(); +interface MockPlantRefCurrent { position: { set: Function; }; } -interface MockRef { - current: MockRefCurrent | undefined; +interface MockRadiusRefCurrent { + scale: { set: Function; }; } -const mockRef: MockRef = { current: undefined }; +interface MockPlantRef { + current: MockPlantRefCurrent | undefined; +} +interface MockRadiusRef { + current: MockRadiusRefCurrent | undefined; +} +const mockPlantRef: MockPlantRef = { current: undefined }; +const mockRadiusRef: MockRadiusRef = { current: undefined }; jest.mock("react", () => ({ ...jest.requireActual("react"), - useRef: () => mockRef, + useRef: jest.fn(), })); import React from "react"; @@ -28,8 +36,17 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { dropPlant } from "../../../farm_designer/map/layers/plants/plant_actions"; import { Path } from "../../../internal_urls"; import { fakeAddPlantProps } from "../../../__test_support__/fake_props"; +import { Actions } from "../../../constants"; +import { fakeDrawnPoint } from "../../../__test_support__/fake_designer_state"; +import { mockDispatch } from "../../../__test_support__/fake_dispatch"; describe("", () => { + beforeEach(() => { + React.useRef = jest.fn() + .mockImplementationOnce(() => mockPlantRef) + .mockImplementationOnce(() => mockRadiusRef); + }); + const fakeProps = (): BedProps => ({ config: clone(INITIAL), activeFocus: "", @@ -63,22 +80,83 @@ describe("", () => { })); }); + it("doesn't add a drawn point", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const addPlantProps = fakeAddPlantProps([]); + addPlantProps.designer.drawnPoint = undefined; + p.addPlantProps = addPlantProps; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.click(soil); + expect(p.addPlantProps.dispatch).not.toHaveBeenCalled(); + }); + + it("adds a drawn point: xy", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const addPlantProps = fakeAddPlantProps([]); + const point = fakeDrawnPoint(); + point.cx = undefined; + point.cy = undefined; + point.r = 0; + addPlantProps.designer.drawnPoint = point; + p.addPlantProps = addPlantProps; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.click(soil); + expect(p.addPlantProps.dispatch).toHaveBeenCalledWith({ + type: Actions.SET_DRAWN_POINT_DATA, + payload: { ...point, cx: 1360, cy: 660, z: -500 }, + }); + expect(p.addPlantProps.dispatch).toHaveBeenCalledTimes(1); + }); + + it("adds a drawn point: radius", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const addPlantProps = fakeAddPlantProps([]); + const dispatch = jest.fn(); + addPlantProps.dispatch = mockDispatch(dispatch); + const point = fakeDrawnPoint(); + point.cx = 10; + point.cy = 20; + addPlantProps.designer.drawnPoint = point; + p.addPlantProps = addPlantProps; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.click(soil); + expect(p.addPlantProps.dispatch).toHaveBeenCalledWith({ + type: Actions.SET_DRAWN_POINT_DATA, + payload: { ...point, r: 1490 }, + }); + expect(p.addPlantProps.dispatch).toHaveBeenCalledWith({ + type: Actions.SET_DRAWN_POINT_DATA, + payload: undefined, + }); + expect(p.addPlantProps.dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.INIT_RESOURCE, + payload: expect.any(Object), + }); + }); + it("updates pointer plant position", () => { location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; - mockRef.current = { position: { set: mockSetPosition } }; + mockPlantRef.current = { position: { set: mockSetPosition } }; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps([]); render(); const soil = screen.getAllByText("soil")[0]; fireEvent.pointerMove(soil); - expect(mockSetPosition).toHaveBeenCalledWith(0, 0, -75); + expect(mockSetPosition).toHaveBeenCalledWith(0, 0, 0); }); it("handles missing ref", () => { location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; - mockRef.current = undefined; + mockPlantRef.current = undefined; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps([]); render(); @@ -90,12 +168,99 @@ describe("", () => { it("doesn't update pointer plant position: mobile", () => { location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = true; - mockRef.current = { position: { set: mockSetPosition } }; + mockPlantRef.current = { position: { set: mockSetPosition } }; + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps([]); + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.pointerMove(soil); + expect(mockSetPosition).not.toHaveBeenCalled(); + }); + + it("doesn't update pointer point position", () => { + location.pathname = Path.mock(Path.points("add")); + mockIsMobile = false; + mockPlantRef.current = { position: { set: mockSetPosition } }; + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps([]); + p.addPlantProps.designer.drawnPoint = undefined; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.pointerMove(soil); + expect(mockSetPosition).not.toHaveBeenCalled(); + }); + + it("updates pointer point position", () => { + location.pathname = Path.mock(Path.points("add")); + mockIsMobile = false; + mockPlantRef.current = { position: { set: mockSetPosition } }; + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps([]); + const point = fakeDrawnPoint(); + point.cx = undefined; + point.cy = undefined; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.pointerMove(soil); + expect(mockSetPosition).toHaveBeenCalledWith(0, 0, 0); + }); + + it("updates pointer point radius", () => { + location.pathname = Path.mock(Path.points("add")); + mockIsMobile = false; + mockPlantRef.current = { position: { set: mockSetPosition } }; + mockRadiusRef.current = { scale: { set: mockSetScale } }; + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps([]); + const point = fakeDrawnPoint(); + point.cx = 1; + point.cy = 1; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.pointerMove(soil); + expect(mockSetPosition).not.toHaveBeenCalled(); + expect(mockSetScale).toHaveBeenCalledWith(1510, 1, 1510); + }); + + it("updates pointer weed radius", () => { + location.pathname = Path.mock(Path.weeds("add")); + mockIsMobile = false; + mockPlantRef.current = { position: { set: mockSetPosition } }; + mockRadiusRef.current = { scale: { set: mockSetScale } }; + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps([]); + const point = fakeDrawnPoint(); + point.cx = 1; + point.cy = 1; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; + render(); + const soil = screen.getAllByText("soil")[0]; + fireEvent.pointerMove(soil); + expect(mockSetPosition).not.toHaveBeenCalled(); + expect(mockSetScale).toHaveBeenCalledWith(1510, 1510, 1510); + }); + + it("doesn't update pointer point radius", () => { + location.pathname = Path.mock(Path.points("add")); + mockIsMobile = false; + mockPlantRef.current = { position: { set: mockSetPosition } }; + mockRadiusRef.current = undefined; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps([]); + const point = fakeDrawnPoint(); + point.cx = 1; + point.cy = 1; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; render(); const soil = screen.getAllByText("soil")[0]; fireEvent.pointerMove(soil); expect(mockSetPosition).not.toHaveBeenCalled(); + expect(mockSetScale).not.toHaveBeenCalled(); }); }); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 2bc7543fa1..d571259bad 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -1,30 +1,37 @@ import React from "react"; import { Billboard, Box, Detailed, Extrude, useTexture, Image, + Cylinder, + Sphere, } from "@react-three/drei"; import { DoubleSide, Path as LinePath, Shape, RepeatWrapping, Group as GroupType, + Mesh, } from "three"; -import { range } from "lodash"; +import { isUndefined, range } from "lodash"; import { threeSpace, zZero, getColorFromBrightness } from "../helpers"; import { Config, detailLevels } from "../config"; -import { ASSETS } from "../constants"; +import { ASSETS, DRAW_POINT_MODES, HOVER_OBJECT_MODES } from "../constants"; import { DistanceIndicator } from "../elements"; import { FarmbotAxes, Caster, UtilitiesPost, Packaging } from "./objects"; import { Group, MeshPhongMaterial } from "../components"; -import { getMode, round } from "../../farm_designer/map/util"; +import { getMode, round, xyDistance } from "../../farm_designer/map/util"; import { AxisNumberProperty, Mode, TaggedPlant, } from "../../farm_designer/map/interfaces"; import { dropPlant } from "../../farm_designer/map/layers/plants/plant_actions"; import { TaggedCurve } from "farmbot"; import { GetWebAppConfigValue } from "../../config_storage/actions"; -import { DesignerState } from "../../farm_designer/interfaces"; +import { DesignerState, DrawnPointPayl } from "../../farm_designer/interfaces"; import { isMobile } from "../../screen_size"; import { ThreeEvent } from "@react-three/fiber"; import { Path } from "../../internal_urls"; import { findIcon } from "../../crops/find"; import { DEFAULT_PLANT_RADIUS } from "../../farm_designer/plant"; +import { DrawnPoint } from "../garden"; +import { Actions } from "../../constants"; +import { createPoint } from "../../points/create_points"; +import { useNavigate } from "react-router"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -137,11 +144,14 @@ export const Bed = (props: BedProps) => { // eslint-disable-next-line no-null/no-null const pointerPlantRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const radiusRef = React.useRef(null); + type XY = AxisNumberProperty; - const getGardenPosition = (e: ThreeEvent): XY => ({ - x: round(threeSpace(e.point.x, -bedLengthOuter) - bedXOffset), - y: round(threeSpace(e.point.y, -bedWidthOuter) - bedYOffset), + const getGardenPosition = (threeDPosition: XY): XY => ({ + x: round(threeSpace(threeDPosition.x, -bedLengthOuter) - bedXOffset), + y: round(threeSpace(threeDPosition.y, -bedWidthOuter) - bedYOffset), }); const get3DPosition = (gardenPosition: XY): XY => ({ @@ -157,33 +167,83 @@ export const Bed = (props: BedProps) => { addPlantProps?: AddPlantProps; } + const navigate = useNavigate(); + const Soil = ({ children, addPlantProps }: SoilProps) => { const soilDepth = bedHeight + zZero(props.config) - soilHeight; return ) => { e.stopPropagation(); - if (addPlantProps && getMode() == Mode.clickToAdd) { - dropPlant({ - gardenCoords: getGardenPosition(e), - gridSize: addPlantProps.gridSize, - dispatch: addPlantProps.dispatch, - getConfigValue: addPlantProps.getConfigValue, - plants: addPlantProps.plants, - curves: addPlantProps.curves, - designer: addPlantProps.designer, - }); + if (addPlantProps) { + if (getMode() == Mode.clickToAdd) { + dropPlant({ + gardenCoords: getGardenPosition(e.point), + gridSize: addPlantProps.gridSize, + dispatch: addPlantProps.dispatch, + getConfigValue: addPlantProps.getConfigValue, + plants: addPlantProps.plants, + curves: addPlantProps.curves, + designer: addPlantProps.designer, + }); + } + if (DRAW_POINT_MODES.includes(getMode())) { + const cursor = getGardenPosition(e.point); + const { drawnPoint } = addPlantProps.designer; + if (isUndefined(drawnPoint)) { return; } + const payload: DrawnPointPayl = + (isUndefined(drawnPoint.cx) || isUndefined(drawnPoint.cy)) + ? { + ...drawnPoint, + cx: cursor.x, + cy: cursor.y, + z: -props.config.soilHeight, + } + : { + ...drawnPoint, + cx: drawnPoint.cx, + cy: drawnPoint.cy, + r: round(xyDistance( + { x: drawnPoint.cx, y: drawnPoint.cy }, + cursor)), + }; + addPlantProps.dispatch({ + type: Actions.SET_DRAWN_POINT_DATA, + payload, + }); + if (payload.r) { + createPoint({ + dispatch: addPlantProps.dispatch, + drawnPoint: payload, + navigate: navigate, + }); + } + } } }} onPointerMove={(e: ThreeEvent) => { if (addPlantProps - && getMode() == Mode.clickToAdd + && HOVER_OBJECT_MODES.includes(getMode()) && !isMobile() && pointerPlantRef.current) { - const position = get3DPosition(getGardenPosition(e)); - pointerPlantRef.current.position.set( - position.x, - position.y, - zZero(props.config) - props.config.soilHeight + iconSize / 2); + const position = get3DPosition(getGardenPosition(e.point)); + if (getMode() == Mode.clickToAdd) { + pointerPlantRef.current.position.set(position.x, position.y, 0); + } + if (DRAW_POINT_MODES.includes(getMode())) { + const { drawnPoint } = addPlantProps.designer; + if (isUndefined(drawnPoint)) { return; } + if (isUndefined(drawnPoint.cx) || isUndefined(drawnPoint.cy)) { + pointerPlantRef.current.position.set(position.x, position.y, 0); + } else { + const radius = round(xyDistance( + { x: drawnPoint.cx, y: drawnPoint.cy }, + getGardenPosition(e.point))); + radiusRef.current?.scale.set( + radius, + getMode() == Mode.createPoint ? 1 : radius, + radius); + } + } } }} castShadow={true} @@ -201,6 +261,9 @@ export const Bed = (props: BedProps) => { ; }; + const drawnPoint = props.addPlantProps && props.addPlantProps.designer.drawnPoint; + const soilZ = zZero(props.config) - props.config.soilHeight; + return @@ -274,16 +337,53 @@ export const Bed = (props: BedProps) => { ]}> - {getMode() == Mode.clickToAdd && !isMobile() && - - - } + {HOVER_OBJECT_MODES.includes(getMode()) && + !isMobile() && + + + {DRAW_POINT_MODES.includes(getMode()) && props.addPlantProps && + drawnPoint && + + {(isUndefined(drawnPoint.cx) || isUndefined(drawnPoint.cy)) + ? + : + {getMode() == Mode.createPoint + ? + + + : + + } + } + } + {getMode() == Mode.clickToAdd && + + + } + + } { position.z, ]} onClick={() => { - if (slotProps.id && !isUndefined(props.dispatch)) { + if (slotProps.id && !isUndefined(props.dispatch) && + !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); navigate(Path.toolSlots(slotProps.id)); } diff --git a/frontend/three_d_garden/constants.ts b/frontend/three_d_garden/constants.ts index bb548a7b42..22e14d9821 100644 --- a/frontend/three_d_garden/constants.ts +++ b/frontend/three_d_garden/constants.ts @@ -1,5 +1,6 @@ /* eslint-disable max-len */ import { sampleSize } from "lodash"; +import { Mode } from "../farm_designer/map/interfaces"; export const LIB_DIR = "/3D/lib/"; @@ -76,6 +77,16 @@ export const ASSETS: Record> = { }, }; +export const HOVER_OBJECT_MODES = [ + Mode.clickToAdd, + Mode.createPoint, + Mode.createWeed, +]; +export const DRAW_POINT_MODES = [ + Mode.createPoint, + Mode.createWeed, +]; + interface Plant { label: string; spread: number; diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 38a1c665ac..f0341947b0 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -92,6 +92,7 @@ describe("", () => { i: 0, config: config, hoveredPlant: undefined, + visible: true, }; }; diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index b145453945..cbbf3631eb 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -1,12 +1,15 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { Point, PointProps } from "../point"; +import { DrawnPoint, DrawnPointProps, 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"; +import { + fakeDesignerState, fakeDrawnPoint, +} from "../../../__test_support__/fake_designer_state"; describe("", () => { const fakeProps = (): PointProps => ({ @@ -43,3 +46,49 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + +describe("", () => { + const fakeProps = (): DrawnPointProps => { + const designer = fakeDesignerState(); + designer.drawnPoint = fakeDrawnPoint(); + const config = clone(INITIAL); + return { + designer, + usePosition: false, + config, + }; + }; + + it("draws point", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + p.designer.drawnPoint = undefined; + const { container } = render(); + expect(container).toContainHTML("position=\"0,0,0\""); + }); + + it("draws weed", () => { + location.pathname = Path.mock(Path.weeds("add")); + const p = fakeProps(); + const { container } = render(); + expect(container).toContainHTML("generic-weed"); + expect(container).toContainHTML("position=\"0,0,0\""); + expect(container).toContainHTML("scale=\"30\""); + expect(container).toContainHTML("color=\"green\""); + expect(container).toContainHTML("opacity=\"0.25\""); + }); + + it("draws weed: no radius", () => { + location.pathname = Path.mock(Path.weeds("add")); + const p = fakeProps(); + const point = fakeDrawnPoint(); + point.r = 0; + p.designer.drawnPoint = point; + const { container } = render(); + expect(container).toContainHTML("generic-weed"); + expect(container).toContainHTML("position=\"0,0,0\""); + expect(container).toContainHTML("scale=\"50\""); + expect(container).toContainHTML("color=\"green\""); + expect(container).toContainHTML("opacity=\"0.25\""); + }); +}); diff --git a/frontend/three_d_garden/garden/__tests__/sky_test.tsx b/frontend/three_d_garden/garden/__tests__/sky_test.tsx index 98ebf5bc36..841c700da8 100644 --- a/frontend/three_d_garden/garden/__tests__/sky_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sky_test.tsx @@ -1,13 +1,15 @@ import React from "react"; -import { mount } from "enzyme"; +import { render } from "@testing-library/react"; import { Sky, SkyProps } from "../sky"; +import { Vector3 } from "three"; describe("", () => { const fakeProps = (): SkyProps => ({ + sunPosition: new Vector3(), }); it("renders", () => { - const wrapper = mount(); - expect(wrapper.html()).toContain("primitive"); + const { container } = render(); + expect(container).toContainHTML("primitive"); }); }); diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index c2effdea4a..b70a085816 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -25,7 +25,7 @@ describe("", () => { p.dispatch = mockDispatch(dispatch); p.weed.body.id = 1; const { container } = render(); - const weed = container.querySelector("[name='weed'"); + const weed = container.querySelector("[name='weed-1'"); weed && fireEvent.click(weed); expect(dispatch).toHaveBeenCalledWith({ type: Actions.SET_PANEL_OPEN, payload: true, diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index bb18384e9f..b99a9a80a9 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -1,6 +1,6 @@ import { TaggedPlant } from "../../farm_designer/map/interfaces"; import { Config } from "../config"; -import { GARDENS, PLANTS } from "../constants"; +import { GARDENS, HOVER_OBJECT_MODES, PLANTS } from "../constants"; import { Billboard, Image } from "@react-three/drei"; import React from "react"; import { Vector3 } from "three"; @@ -11,6 +11,7 @@ import { isUndefined, kebabCase } from "lodash"; import { Path } from "../../internal_urls"; import { useNavigate } from "react-router"; import { setPanelOpen } from "../../farm_designer/panel_header"; +import { getMode } from "../../farm_designer/map/util"; interface Plant { id?: number | undefined; @@ -91,6 +92,7 @@ export interface ThreeDPlantProps { config: Config; hoveredPlant: number | undefined; dispatch?: Function; + visible?: boolean; } export const ThreeDPlant = (props: ThreeDPlantProps) => { @@ -114,7 +116,8 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { : { - if (plant.id && !isUndefined(props.dispatch)) { + if (plant.id && !isUndefined(props.dispatch) && props.visible && + !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); navigate(Path.plants(plant.id)); } diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index 792506ea97..7ec220d95a 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { TaggedGenericPointer } from "farmbot"; +import { TaggedGenericPointer, Xyz } from "farmbot"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; import { Cylinder, Sphere } from "@react-three/drei"; @@ -9,6 +9,11 @@ import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; import { isUndefined } from "lodash"; import { setPanelOpen } from "../../farm_designer/panel_header"; +import { DesignerState } from "../../farm_designer/interfaces"; +import { getMode } from "../../farm_designer/map/util"; +import { Mode } from "../../farm_designer/map/interfaces"; +import { WeedBase } from "."; +import { HOVER_OBJECT_MODES } from "../constants"; export interface PointProps { point: TaggedGenericPointer; @@ -18,50 +23,104 @@ export interface PointProps { export const Point = (props: PointProps) => { const { point, config } = props; + const navigate = useNavigate(); + return { + if (point.body.id && !isUndefined(props.dispatch) && + !HOVER_OBJECT_MODES.includes(getMode())) { + props.dispatch(setPanelOpen(true)); + navigate(Path.points(point.body.id)); + } + }} + config={config} + color={point.body.meta.color} + radius={point.body.radius} + />; +}; + +export interface DrawnPointProps { + designer: DesignerState; + usePosition: boolean; + config: Config; +} + +export const DrawnPoint = (props: DrawnPointProps) => { + const { config } = props; + const { drawnPoint } = props.designer; + const drawnPointPosition = + drawnPoint && !isUndefined(drawnPoint.cx) && !isUndefined(drawnPoint.cy) + ? { x: drawnPoint.cx, y: drawnPoint.cy, z: drawnPoint.z } + : undefined; + if (props.usePosition && isUndefined(drawnPointPosition)) { return <>; } + const Base = getMode() == Mode.createWeed ? WeedBase : PointBase; + return ; +}; + +interface PointBaseProps { + pointName: string; + position?: Record; + onClick?: () => void; + color: string | undefined; + radius: number; + alpha: number; + config: Config; +} + +const PointBase = (props: PointBaseProps) => { const RADIUS = 25; const HEIGHT = 100; - const navigate = useNavigate(); + const { config } = props; return + position={props.position + ? [ + threeSpace(props.position.x, config.bedLengthOuter) + config.bedXOffset, + threeSpace(props.position.y, config.bedWidthOuter) + config.bedYOffset, + zeroFunc(config).z + props.position.z, + ] + : [0, 0, 0]}> { - if (point.body.id && !isUndefined(props.dispatch)) { - props.dispatch(setPanelOpen(true)); - navigate(Path.points(point.body.id)); - } - }}> + onClick={props.onClick}> + opacity={1 * props.alpha} /> + opacity={1 * props.alpha} /> + args={[props.radius, props.radius, 100, 32, 32, true]}> + opacity={0.5 * props.alpha} /> ; }; diff --git a/frontend/three_d_garden/garden/sky.tsx b/frontend/three_d_garden/garden/sky.tsx index c2eb96b49d..ccd02ff53c 100644 --- a/frontend/three_d_garden/garden/sky.tsx +++ b/frontend/three_d_garden/garden/sky.tsx @@ -1,51 +1,23 @@ -// Forked from https://github.com/pmndrs/drei/blob/master/src/core/Sky.tsx - import React from "react"; -import { Vector3 as Vector3Type } from "@react-three/fiber"; import { Sky as SkyImpl } from "three-stdlib"; import { Vector3 } from "three"; -import { ForwardRefComponent } from "@react-three/drei/helpers/ts-utils"; import { Primitive } from "../components"; export type SkyProps = { - distance?: number - sunPosition?: Vector3Type - mieCoefficient?: number - mieDirectionalG?: number - rayleigh?: number - turbidity?: number - up?: Vector3Type + sunPosition: Vector3; } -export const Sky: ForwardRefComponent = - /* @__PURE__ */ - React.forwardRef(( - { - distance = 1000, - mieCoefficient = 0.005, - mieDirectionalG = 0.8, - rayleigh = 0.5, - turbidity = 10, - sunPosition = [0, 5000, 0], - up = [0, 0, 1], - ...props - }: SkyProps, - ref, - ) => { - const scale = React.useMemo(() => new Vector3() - .setScalar(distance), [distance]); - const [sky] = React.useState(() => new SkyImpl()); +export const Sky = (props: SkyProps) => { + const [sky] = React.useState(() => new SkyImpl()); - return ; - }); + return ; +}; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index 17e1f9882f..002732bcc0 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { TaggedWeedPointer } from "farmbot"; +import { TaggedWeedPointer, Xyz } from "farmbot"; import { Config } from "../config"; -import { ASSETS } from "../constants"; +import { ASSETS, HOVER_OBJECT_MODES } from "../constants"; import { Group, MeshPhongMaterial } from "../components"; import { Image, Billboard, Sphere } from "@react-three/drei"; import { DoubleSide } from "three"; @@ -10,6 +10,7 @@ import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; import { isUndefined } from "lodash"; import { setPanelOpen } from "../../farm_designer/panel_header"; +import { getMode } from "../../farm_designer/map/util"; export interface WeedProps { weed: TaggedWeedPointer; @@ -20,34 +21,66 @@ export interface WeedProps { export const Weed = (props: WeedProps) => { const { weed, config } = props; const navigate = useNavigate(); - return { - if (weed.body.id && !isUndefined(props.dispatch)) { + if (weed.body.id && !isUndefined(props.dispatch) && + !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); navigate(Path.weeds(weed.body.id)); } }} - position={[ - threeSpace(weed.body.x, config.bedLengthOuter) + config.bedXOffset, - threeSpace(weed.body.y, config.bedWidthOuter) + config.bedYOffset, - zeroFunc(config).z - config.soilHeight, - ]}> + position={{ + x: weed.body.x, + y: weed.body.y, + z: -config.soilHeight, + }} + config={config} + color={weed.body.meta.color} + radius={weed.body.radius} />; +}; + +interface WeedBaseProps { + pointName: string; + position?: Record; + onClick?: () => void; + color: string | undefined; + radius: number; + alpha: number; + config: Config; +} + +export const WeedBase = (props: WeedBaseProps) => { + const { config } = props; + const iconSize = props.radius ? props.radius : 50; + return + position={[0, 0, iconSize / 2]}> + opacity={0.5 * props.alpha} /> ; }; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 20d0d5307f..91a0295951 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -13,6 +13,7 @@ import { Sky, Solar, Sun, sunPosition, ZoomBeacons, calculatePlantPositions, convertPlants, ThreeDPlant, Point, Grid, Clouds, Ground, Weed, + DrawnPoint, } from "./garden"; import { Config } from "./config"; import { useSpring, animated } from "@react-spring/three"; @@ -27,6 +28,8 @@ import { TaggedGenericPointer, TaggedWeedPointer } from "farmbot"; import { BooleanSetting } from "../session_keys"; import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; +import { getMode } from "../farm_designer/map/util"; +import { DRAW_POINT_MODES } from "./constants"; const AnimatedGroup = animated(Group); @@ -41,14 +44,15 @@ export interface GardenModelProps { mountedToolName?: string | undefined; } +// eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { - const { config } = props; - const dispatch = props.addPlantProps?.dispatch; + const { config, addPlantProps } = props; + const dispatch = addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; - const plants = isUndefined(props.addPlantProps) + const plants = isUndefined(addPlantProps) ? calculatePlantPositions(config) - : convertPlants(config, props.addPlantProps.plants); + : convertPlants(config, addPlantProps.plants); const [hoveredPlant, setHoveredPlant] = React.useState(undefined); @@ -76,6 +80,14 @@ export const GardenModel = (props: GardenModelProps) => { const camera = getCamera(config, props.activeFocus, cameraInit()); + const showPlants = !addPlantProps + || !!addPlantProps.getConfigValue(BooleanSetting.show_plants); + const plantsVisible = props.activeFocus != "Planter bed" && showPlants; + const showFarmbot = !addPlantProps + || !!addPlantProps.getConfigValue(BooleanSetting.show_farmbot); + const showPoints = !!addPlantProps?.getConfigValue(BooleanSetting.show_points); + const showWeeds = !!addPlantProps?.getConfigValue(BooleanSetting.show_weeds); + // eslint-disable-next-line no-null/no-null return { config={config} activeFocus={props.activeFocus} setActiveFocus={props.setActiveFocus} />} - + @@ -117,9 +124,8 @@ export const GardenModel = (props: GardenModelProps) => { - {(!props.addPlantProps - || !!props.addPlantProps.getConfigValue(BooleanSetting.show_farmbot)) && + addPlantProps={addPlantProps} /> + {showFarmbot && { {plants.map((plant, i) => )} + visible={showPoints}> {props.mapPoints?.map(point => )} + {addPlantProps && DRAW_POINT_MODES.includes(getMode()) && + } + visible={showWeeds}> {props.weeds?.map(weed => { describe("docLinkClick", () => { it("navigates to doc link", () => { location.pathname = Path.mock(Path.designer()); - docLinkClick("farmware")(); - expect(mockNavigate).toHaveBeenCalledWith(Path.help("farmware")); + const navigate = jest.fn(); + docLinkClick("farmware", navigate)(); + expect(navigate).toHaveBeenCalledWith(Path.help("farmware")); expect(location.assign).not.toHaveBeenCalled(); }); it("reloads to doc link", () => { location.pathname = Path.mock(Path.help()); location.assign = jest.fn(); - docLinkClick("farmware")(); - expect(mockNavigate).not.toHaveBeenCalled(); + const navigate = jest.fn(); + docLinkClick("farmware", navigate)(); + expect(navigate).not.toHaveBeenCalled(); expect(location.assign).toHaveBeenCalledWith(expect.stringContaining( Path.help("farmware"))); }); @@ -47,8 +49,9 @@ describe("docLinkClick", () => { describe("devDocLinkClick", () => { it("navigates to doc link", () => { location.pathname = Path.mock(Path.designer()); - devDocLinkClick("lua")(); - expect(mockNavigate).toHaveBeenCalledWith(Path.developer("lua")); + const navigate = jest.fn(); + devDocLinkClick("lua", navigate)(); + expect(navigate).toHaveBeenCalledWith(Path.developer("lua")); expect(location.assign).not.toHaveBeenCalled(); }); }); diff --git a/frontend/ui/doc_link.ts b/frontend/ui/doc_link.ts index dadd875d87..636f2f39d3 100644 --- a/frontend/ui/doc_link.ts +++ b/frontend/ui/doc_link.ts @@ -1,5 +1,5 @@ import { ExternalUrl } from "../external_urls"; -import { useNavigate } from "react-router"; +import { NavigateFunction } from "react-router"; import { Path } from "../internal_urls"; /** A centralized list of all documentation slugs in the app makes it easier to @@ -38,18 +38,22 @@ export const devDocLink = (slug?: DevDocSlug) => export const genesisDocLink = (slug?: GenesisDocSlug) => `${ExternalUrl.genesisDocs}/${slug || ""}`; -const genericDocLinkClick = (slug: T, page: "help" | "developer") => () => { - const path = page == "help" ? Path.help : Path.developer; - if (Path.getSlug(Path.designer()) == page) { - location.assign(window.location.origin + Path.withApp(path("" + slug))); - } else { - const navigate = useNavigate(); - navigate(path("" + slug)); - } -}; - -export const docLinkClick = (slug: DocSlug) => - genericDocLinkClick(slug, "help"); - -export const devDocLinkClick = (slug: DevDocSlug) => - genericDocLinkClick(slug, "developer"); +const genericDocLinkClick = + ( + slug: T, + page: "help" | "developer", + navigate: NavigateFunction, + ) => () => { + const path = page == "help" ? Path.help : Path.developer; + if (Path.getSlug(Path.designer()) == page) { + location.assign(window.location.origin + Path.withApp(path("" + slug))); + } else { + navigate(path("" + slug)); + } + }; + +export const docLinkClick = (slug: DocSlug, navigate: NavigateFunction) => + genericDocLinkClick(slug, "help", navigate); + +export const devDocLinkClick = (slug: DevDocSlug, navigate: NavigateFunction) => + genericDocLinkClick(slug, "developer", navigate); diff --git a/frontend/ui/tooltip.tsx b/frontend/ui/tooltip.tsx index b6ed472fde..c2bd06f827 100644 --- a/frontend/ui/tooltip.tsx +++ b/frontend/ui/tooltip.tsx @@ -1,6 +1,7 @@ import React from "react"; import { t } from "../i18next_wrapper"; import { DocSlug, docLinkClick } from "./doc_link"; +import { useNavigate } from "react-router"; export interface ToolTipProps { children?: React.ReactNode; @@ -12,6 +13,7 @@ export interface ToolTipProps { export const ToolTip = (props: ToolTipProps) => { const { helpText, className } = props; const [isOpen, setIsOpen] = React.useState(false); + const navigate = useNavigate(); return
x).join(" ")} onClick={e => e.stopPropagation()}> {
{t(helpText)} {props.docPage && - + {" " + t("Documentation")} } diff --git a/frontend/wizard/checks.tsx b/frontend/wizard/checks.tsx index 90374bd0cc..6e2f082d5c 100644 --- a/frontend/wizard/checks.tsx +++ b/frontend/wizard/checks.tsx @@ -102,6 +102,7 @@ import { WaterFlowRateInput } from "../tools/edit_tool"; import { RPI_OPTIONS } from "../settings/fbos_settings/rpi_model"; import { BoxTop } from "../settings/pin_bindings/box_top"; import { OtaTimeSelector } from "../settings/fbos_settings/ota_time_selector"; +import { useNavigate } from "react-router"; export const Language = (props: WizardStepComponentProps) => { const user = getUserAccountSettings(props.resources); @@ -190,7 +191,9 @@ export const CameraCalibrationCard = (props: WizardStepComponentProps) => { export const SwitchCameraCalibrationMethod = (props: WizardOutcomeComponentProps) => { + const navigate = useNavigate(); return envGet(key, prepopulateEnv(getEnv(props.resources)))} saveEnvVar={(key, value) => props.dispatch(saveOrEditFarmwareEnv(props.resources)( diff --git a/frontend/wizard/interfaces.ts b/frontend/wizard/interfaces.ts index cf00f25a74..666d28db85 100644 --- a/frontend/wizard/interfaces.ts +++ b/frontend/wizard/interfaces.ts @@ -56,7 +56,7 @@ export interface WizardOutcomeComponentProps { export interface WizardStepComponentProps extends WizardOutcomeComponentProps { setStepSuccess(success: boolean, outcome?: string): () => void; - navigate: NavigateFunction + navigate: NavigateFunction; } interface ComponentOptions { diff --git a/package.json b/package.json index 88f9a498e8..bf201559a1 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ }, "scripts": { "test-very-slow": "node --expose-gc ./node_modules/.bin/jest -i --colors --coverage", - "test-slow": "./node_modules/.bin/jest -w 6 --colors", - "test": "./node_modules/.bin/jest -w 5 --no-coverage", + "test-slow": "./node_modules/.bin/jest -w 3 --colors", + "test": "./node_modules/.bin/jest -w 3 --no-coverage", "graph-modules-dot": "./node_modules/.bin/madge --dot ./frontend > module_graph.dot", "graph-modules-svg": "dot -Tsvg module_graph.dot -o module_graph.svg", "typecheck": "./node_modules/typescript/bin/tsc --noEmit", @@ -37,8 +37,8 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "5.17.3", - "@blueprintjs/select": "5.3.15", + "@blueprintjs/core": "5.17.5", + "@blueprintjs/select": "5.3.17", "@monaco-editor/react": "4.7.0", "@parcel/transformer-sass": "2.13.3", "@parcel/transformer-typescript-tsc": "2.13.3", @@ -48,15 +48,15 @@ "@rollbar/react": "0.12.1", "@types/lodash": "4.17.16", "@types/markdown-it": "14.1.2", - "@types/node": "22.13.9", + "@types/node": "22.13.10", "@types/promise-timeout": "1.3.3", "@types/react": "19.0.10", "@types/react-color": "3.0.13", "@types/react-dom": "19.0.4", "@types/three": "0.174.0", - "@types/ws": "8.5.14", + "@types/ws": "8.18.0", "@xterm/xterm": "5.5.0", - "axios": "1.8.1", + "axios": "1.8.2", "bowser": "2.11.0", "browser-speech": "1.1.1", "events": "3.3.0", @@ -68,7 +68,7 @@ "moment": "2.30.1", "monaco-editor": "0.52.2", "mqtt": "5.10.4", - "npm": "11.1.0", + "npm": "11.2.0", "parcel": "2.13.3", "process": "0.11.10", "promise-timeout": "1.3.0", @@ -78,7 +78,7 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.2.0", + "react-router": "7.3.0", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0",