diff --git a/.circleci/config.yml b/.circleci/config.yml index a2dfec6c19..663b1c3e8c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,12 +27,13 @@ commands: command: | mv .circleci/circle_envs .env echo -e '\ndocker_volumes/db/pg_wal/*' >> .dockerignore + sudo docker compose build web sudo docker compose run web gem install bundler sudo docker compose run web bundle install sudo docker compose run web npm install sudo docker compose run web bundle exec rails db:create sudo docker compose run web bundle exec rails db:migrate - sudo docker compose run web rake keys:generate + sudo docker compose run web bundle exec rake keys:generate - run: name: After cache update command: | @@ -52,11 +53,11 @@ commands: - run: name: Run Ruby Tests command: | - sudo docker compose run web rspec spec --format progress --format RspecJunitFormatter --out /tmp/test-results/rspec/rspec.xml + sudo docker compose run web bundle exec rspec spec --format progress --format RspecJunitFormatter --out /tmp/test-results/rspec/rspec.xml - run: name: Check app coverage status command: | - sudo docker compose run web rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] + sudo docker compose run web bundle exec rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] when: always - run: name: Upload app coverage to Codecov @@ -88,13 +89,13 @@ commands: - run: name: Check frontend coverage status command: | - sudo docker compose run -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] + sudo docker compose run -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web bundle exec rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] when: always - run: name: Check frontend file coverage status command: | changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true - sudo docker compose run -e CHANGED_FILES="$changed" web rake check_file_coverage:fe || true + sudo docker compose run -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true when: always - run: name: Report frontend coverage to Coveralls diff --git a/Gemfile.lock b/Gemfile.lock index 6effdf408b..9f32289b0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,7 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.15) + active_model_serializers (0.10.16) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) @@ -65,8 +65,8 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) amq-protocol (2.3.4) base64 (0.3.0) bcrypt (3.1.20) @@ -91,7 +91,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.5.0) + date (3.5.1) declarative (0.0.20) delayed_job (4.1.13) activesupport (>= 3.0, < 9.0) @@ -118,7 +118,7 @@ GEM factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.5.2) + faker (3.5.3) i18n (>= 1.8.11, < 2) faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) @@ -126,8 +126,8 @@ GEM logger faraday-follow_redirects (0.4.0) faraday (>= 1, < 3) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) google-apis-core (1.0.2) @@ -138,9 +138,9 @@ GEM mini_mime (~> 1.1) representable (~> 3.0) retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.25.0) + google-apis-iamcredentials_v1 (0.26.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.57.0) + google-apis-storage_v1 (0.58.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -149,7 +149,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.5.0) - google-cloud-storage (1.57.0) + google-cloud-storage (1.57.1) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -159,7 +159,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.15.1) + googleauth (1.16.0) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -168,10 +168,10 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.2.1) - hashie (4.1.0) + hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.15.2) + json (2.17.1) jsonapi-renderer (0.2.2) jwt (3.1.2) base64 @@ -205,13 +205,13 @@ GEM marcel (1.1.0) method_source (1.1.0) mini_mime (1.1.5) - minitest (5.26.0) - multi_json (1.17.0) + minitest (5.26.2) + multi_json (1.18.0) mutations (0.9.1) activesupport mutex_m (0.3.0) - net-http (0.7.0) - uri + net-http (0.8.0) + uri (>= 0.11.1) net-imap (0.5.12) date net-protocol @@ -240,15 +240,15 @@ GEM method_source (~> 1.0) pry-rails (0.3.11) pry (>= 0.13.0) - public_suffix (6.0.2) - rabbitmq_http_api_client (3.0.0) + public_suffix (7.0.0) + rabbitmq_http_api_client (3.2.2) addressable (~> 2.7) faraday (~> 2.9) faraday-follow_redirects (~> 0.3) - hashie (~> 4.1) + hashie (>= 4.1) multi_json (~> 1.15) racc (1.8.1) - rack (2.2.20) + rack (2.2.21) rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -305,7 +305,7 @@ GEM railties (>= 5.2) retriable (3.1.2) rexml (3.4.4) - rollbar (3.6.2) + rollbar (3.7.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -362,14 +362,14 @@ GEM thor (1.4.0) thwait (0.2.0) e2mmap - timeout (0.4.4) + timeout (0.5.0) trailblazer-option (0.1.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) tzinfo-data (1.2025.2) tzinfo (>= 1.0.0) uber (0.1.0) - uri (1.1.0) + uri (1.1.1) valid_url (0.0.4) addressable rails @@ -379,7 +379,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) + webrick (1.9.2) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -439,7 +439,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.7p58 BUNDLED WITH - 2.7.2 + 4.0.1 diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 522ce8797b..134a955d85 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -56,6 +56,17 @@ jest.mock("../three_d_garden/components", () => ({ // @ts-expect-error Property does not exist on type JSX.IntrinsicElements return
; }, + MeshPhongMaterial: (props: THREE.MeshPhongMaterial) => { + props.onBeforeCompile?.( + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + { uniforms: {}, vertexShader: "", fragmentShader: "" } as any, + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + {} as any); + // @ts-expect-error Property does not exist on type JSX.IntrinsicElements + return
; + }, })); jest.mock("three/examples/jsm/Addons.js", () => ({ diff --git a/frontend/api/__tests__/crud_test.ts b/frontend/api/__tests__/crud_test.ts index bd301e7046..a9d846b600 100644 --- a/frontend/api/__tests__/crud_test.ts +++ b/frontend/api/__tests__/crud_test.ts @@ -1,6 +1,8 @@ -import { urlFor } from "../crud"; +import { batchInitDirty, urlFor } from "../crud"; import { API } from "../api"; import { ResourceName } from "farmbot"; +import { fakePlant } from "../../__test_support__/fake_state/resources"; +import { Actions } from "../../constants"; describe("urlFor()", () => { API.setBaseUrl(""); @@ -10,3 +12,14 @@ describe("urlFor()", () => { .toThrow(/NewResourceWithoutURLHandler/); }); }); + +describe("batchInitDirty()", () => { + it("inits", () => { + const { body } = fakePlant(); + expect(batchInitDirty("Point", [body])) + .toEqual({ + type: Actions.BATCH_INIT, + payload: [expect.objectContaining({ body })], + }); + }); +}); diff --git a/frontend/api/crud.ts b/frontend/api/crud.ts index 532919d25e..15a6d3e803 100644 --- a/frontend/api/crud.ts +++ b/frontend/api/crud.ts @@ -88,6 +88,19 @@ export function init(kind: T["kind"], return { type: Actions.INIT_RESOURCE, payload: resource }; } +export const batchInitDirty = + (kind: T["kind"], bodies: T["body"][]) => { + const resources = bodies.map(body => { + const resource = arrayUnwrap(newTaggedResource(kind, body)); + resource.specialStatus = SpecialStatus.DIRTY; + return resource; + }); + return { + type: Actions.BATCH_INIT, + payload: resources, + }; + }; + /** Initialize and save a new resource, returning the `id`. * If you don't need the `id` returned, use `initSave` instead. */ diff --git a/frontend/constants.ts b/frontend/constants.ts index 93f0b3899e..a05e4caea5 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2446,6 +2446,7 @@ export enum Actions { // Resources DESTROY_RESOURCE_START = "DESTROY_RESOURCE_START", DESTROY_RESOURCE_OK = "DESTROY_RESOURCE_OK", + BATCH_DESTROY_RESOURCE_OK = "BATCH_DESTROY_RESOURCE_OK", INIT_RESOURCE = "INIT_RESOURCE", BATCH_INIT = "BATCH_INIT", SAVE_RESOURCE_OK = "SAVE_RESOURCE_OK", diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 1f1c0b12e9..2d1873b40b 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -131,7 +131,7 @@ describe("", () => { label: "Strawberry Plant 1", seed: 0, size: 50, - spread: 0, + spread: 30, x: 101, y: 201, }], @@ -288,7 +288,7 @@ describe("convertPlants()", () => { label: "Spinach", seed: 0, size: 50, - spread: 0, + spread: 20, x: 110, y: 201, }, diff --git a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx index 633c624a95..9969375748 100644 --- a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx +++ b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx @@ -142,8 +142,8 @@ describe("SpreadOverlapHelper functions", () => { }); it("getContinuousColor()", () => { - expect(getContinuousColor(10, 100)).toEqual("rgba(51, 151, 0, 0.05)"); - expect(getContinuousColor(20, 100)).toEqual("rgba(102, 202, 0, 0.1)"); + expect(getContinuousColor(10, 100).string).toEqual("rgba(51, 151, 0, 0.05)"); + expect(getContinuousColor(20, 100).string).toEqual("rgba(102, 202, 0, 0.1)"); }); it("getRadius()", () => { diff --git a/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx b/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx index aa0afaa66e..e87bd8e899 100644 --- a/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx +++ b/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx @@ -48,13 +48,13 @@ export function getContinuousColor(overlap: number, spreadRadius: number) { const r = Math.min(normalized, 255); const g = Math.min(100 + normalized, 255); // dark instead of bright green const a = Math.min(0.3, Math.round(0.5 * normalized / 510 * 100) / 100); - return `rgba(${r}, ${g}, 0, ${a})`; + return { string: `rgba(${r}, ${g}, 0, ${a})`, rgb: [r / 255, g / 255, 0] }; } else { // yellow to red const g = Math.min(255 * 2 - normalized, 255); - return `rgba(255, ${g}, 0, 0.3)`; + return { string: `rgba(255, ${g}, 0, 0.3)`, rgb: [1, g / 255, 0] }; } } else { - return "none"; + return { string: "none", rgb: [0, 1, 0] }; } } @@ -122,28 +122,56 @@ export function overlapText( } } +export interface GetSpreadRadiiProps { + activeDragSpread: number | undefined; + inactiveSpread: number; + radius: number; +} + +/** Convert spread diameter in cm to radius in mm. */ +export const getSpreadRadii = (props: GetSpreadRadiiProps) => { + return { + active: (props.activeDragSpread || 0) / 2 * 10, + inactive: (props.inactiveSpread || defaultSpreadCmDia(props.radius)) / 2 * 10, + }; +}; + +export interface GetSpreadOverlapColorProps { + spreadRadii: { active: number, inactive: number }; + activeDragXY: BotPosition | undefined; + plantXY: BotPosition; +} + +/** + * Overlap is evaluated against the inactive plant since evaluating + * against the active plant would require keeping a list of all plants + * overlapping the active plant. Therefore, the spread overlap helper + * should be thought of as a tool checking the inactive plants, not + * the plant being edited. Dragging a plant with a small spread into + * the area of a plant with large spread will illustrate this point. + */ +export const getSpreadOverlap = (props: GetSpreadOverlapColorProps) => { + const value = getOverlap(props.activeDragXY, props.plantXY, props.spreadRadii); + const color = getContinuousColor( + value, getRadius(SpreadOption.InactivePlant, props.spreadRadii)); + return { color, value }; +}; + export const SpreadOverlapHelper = (props: SpreadOverlapHelperProps) => { const { dragging, plant, activeDragXY, activeDragSpread, inactiveSpread, mapTransformProps } = props; const { radius, x, y } = plant.body; const { qx, qy } = transformXY(round(x), round(y), mapTransformProps); - const gardenCoord: BotPosition = { x: round(x), y: round(y), z: 0 }; - // Convert spread diameter in cm to radius in mm. - const spreadRadii = { - active: (activeDragSpread || 0) / 2 * 10, - inactive: (inactiveSpread || defaultSpreadCmDia(radius)) / 2 * 10, - }; - - const overlapValue = getOverlap(activeDragXY, gardenCoord, spreadRadii); - // Overlap is evaluated against the inactive plant since evaluating - // against the active plant would require keeping a list of all plants - // overlapping the active plant. Therefore, the spread overlap helper - // should be thought of as a tool checking the inactive plants, not - // the plant being edited. Dragging a plant with a small spread into - // the area of a plant with large spread will illustrate this point. - const color = getContinuousColor( - overlapValue, getRadius(SpreadOption.InactivePlant, spreadRadii)); - + const spreadRadii = getSpreadRadii({ + activeDragSpread, + inactiveSpread, + radius, + }); + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY, + plantXY: { x: round(x), y: round(y), z: 0 }, + }); return {!dragging && // Non-active plants { cx={qx} cy={qy} r={spreadRadii.inactive} - fill={color} />} + fill={overlap.color.string} />} {props.showOverlapValues && !dragging && - overlapText(qx, qy, overlapValue, spreadRadii)} + overlapText(qx, qy, overlap.value, spreadRadii)} ; }; diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index ee188a874a..bc3b6f013d 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -158,7 +158,6 @@ const LayerToggles = (props: LayerTogglesProps) => { submenuTitle={t("extras")} popover={} /> ", () => { const fakeProps = (): AddPlantProps => { @@ -27,10 +28,9 @@ describe("", () => { it("renders", () => { location.pathname = Path.mock(Path.cropSearch("mint/add")); const p = fakeProps(); - p.dispatch = jest.fn(x => x(jest.fn())); + p.dispatch = mockDispatch(jest.fn(), fakeState); const wrapper = mount(); expect(wrapper.text()).toContain("Mint"); - expect(wrapper.text()).toContain("Preview"); const img = wrapper.find("img"); expect(img).toBeDefined(); expect(img.props().src).toEqual("/crops/icons/mint.avif"); diff --git a/frontend/plants/__tests__/crop_info_test.tsx b/frontend/plants/__tests__/crop_info_test.tsx index e1921a2ef2..4781abb9ff 100644 --- a/frontend/plants/__tests__/crop_info_test.tsx +++ b/frontend/plants/__tests__/crop_info_test.tsx @@ -130,7 +130,7 @@ describe("", () => { it("updates curves", () => { location.pathname = Path.mock(Path.cropSearch("mint")); const p = fakeProps(); - mount(); + mount(); expect(p.dispatch).toHaveBeenCalledWith({ type: Actions.SET_CROP_WATER_CURVE_ID, payload: undefined, }); diff --git a/frontend/plants/crop_info.tsx b/frontend/plants/crop_info.tsx index 94b58a0752..2c03e0fdfa 100644 --- a/frontend/plants/crop_info.tsx +++ b/frontend/plants/crop_info.tsx @@ -267,122 +267,127 @@ export function mapStateToProps(props: Everything): CropInfoProps { }; } -export class RawCropInfo extends React.Component { - componentDidMount() { - this.selectMostUsedCurves(Path.getCropSlug()); - } +export const RawCropInfo = (props: CropInfoProps) => { + const [gridOpen, setGridOpen] = React.useState(false); + const toggleOpen = () => setGridOpen(!gridOpen); + React.useEffect(() => { + selectMostUsedCurves(Path.getCropSlug()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - selectMostUsedCurves = (slug: string) => { + const selectMostUsedCurves = (slug: string) => { const findCurve = findMostUsedCurveForCrop({ - plants: this.props.plants, - curves: this.props.curves, + plants: props.plants, + curves: props.curves, slug: slug, }); [CurveType.water, CurveType.spread, CurveType.height].map(curveType => { const id = findCurve(curveType)?.body.id; - changeCurve(this.props.dispatch)(id, curveType); + changeCurve(props.dispatch)(id, curveType); }); }; - render() { - const { dispatch, designer } = this.props; - const slug = Path.getCropSlug(); - const crop = findCrop(slug); - const image = findImage(slug); - const panelName = "crop-info"; - return - !designer.cropSearchQuery && dispatch({ - type: Actions.SEARCH_QUERY_CHANGE, - payload: startCase(slug).toLowerCase(), - })} - style={{ - background: `linear-gradient( + const { dispatch, designer } = props; + const slug = Path.getCropSlug(); + const crop = findCrop(slug); + const image = findImage(slug); + const panelName = "crop-info"; + return + !designer.cropSearchQuery && dispatch({ + type: Actions.SEARCH_QUERY_CHANGE, + payload: startCase(slug).toLowerCase(), + })} + style={{ + background: `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url(${image})` - }} - description={crop.description}> - - - + {t("grid")} - } - content={
- -
} /> -
- - - -
- - dispatch({ - type: Actions.SET_CROP_STAGE, - payload: ddi.value, - })} /> -
-
- - dispatch({ - type: Actions.SET_CROP_PLANTED_AT, - payload: e.currentTarget.value, - }) - } /> -
-
- - dispatch({ - type: Actions.SET_CROP_RADIUS, - payload: parseInt(e.currentTarget.value), - }) - } /> -
- - -
-
; - } -} + }} + description={crop.description}> + + + + {t("grid")} + } + content={
+ +
} /> +
+ + + +
+ + dispatch({ + type: Actions.SET_CROP_STAGE, + payload: ddi.value, + })} /> +
+
+ + dispatch({ + type: Actions.SET_CROP_PLANTED_AT, + payload: e.currentTarget.value, + }) + } /> +
+
+ + dispatch({ + type: Actions.SET_CROP_RADIUS, + payload: parseInt(e.currentTarget.value), + }) + } /> +
+ + +
+
; +}; export const CropInfo = connect(mapStateToProps)(RawCropInfo); // eslint-disable-next-line import/no-default-export diff --git a/frontend/plants/grid/__tests__/grid_input_test.tsx b/frontend/plants/grid/__tests__/grid_input_test.tsx index 870dabb68d..16f977a79d 100644 --- a/frontend/plants/grid/__tests__/grid_input_test.tsx +++ b/frontend/plants/grid/__tests__/grid_input_test.tsx @@ -19,7 +19,6 @@ describe("", () => { grid: testGridInputs(), xy_swap: true, onChange: jest.fn(() => jest.fn()), - preview: jest.fn(), botPosition: { x: undefined, y: undefined, z: undefined }, onUseCurrentPosition: jest.fn(), }); @@ -59,7 +58,6 @@ describe("", () => { gridKey: "numPlantsH", xy_swap: false, onChange: jest.fn(), - preview: jest.fn(), grid: testGridInputs(), }); @@ -88,7 +86,6 @@ describe("", () => { const wrapper = shallow(); wrapper.find("input").first().simulate("blur"); expect(p.onChange).toHaveBeenCalledWith(p.gridKey, 2); - expect(p.preview).toHaveBeenCalled(); expect(wrapper.find("input").props().value).toEqual("2"); }); @@ -100,7 +97,6 @@ describe("", () => { }); expect(wrapper.find("input").props().value).toEqual(""); wrapper.find("input").first().simulate("blur"); - expect(p.preview).not.toHaveBeenCalled(); expect(wrapper.find("input").props().value).toEqual("2"); }); }); diff --git a/frontend/plants/grid/__tests__/plant_grid_test.tsx b/frontend/plants/grid/__tests__/plant_grid_test.tsx index 65756e8544..53cc18eed9 100644 --- a/frontend/plants/grid/__tests__/plant_grid_test.tsx +++ b/frontend/plants/grid/__tests__/plant_grid_test.tsx @@ -4,20 +4,24 @@ jest.mock("../thunks", () => ({ })); jest.mock("../../../api/crud", () => ({ - init: jest.fn(), + batchInitDirty: jest.fn(), })); import React from "react"; import { mount } from "enzyme"; -import { PlantGrid } from "../plant_grid"; +import { MAX_N, PlantGrid } from "../plant_grid"; import { saveGrid, stashGrid } from "../thunks"; import { error, success } from "../../../toast/toast"; import { PlantGridProps } from "../interfaces"; -import { init } from "../../../api/crud"; +import { batchInitDirty } from "../../../api/crud"; import { Actions } from "../../../constants"; import { fakeDesignerState } from "../../../__test_support__/fake_designer_state"; describe("", () => { + beforeEach(() => { + console.debug = jest.fn(); + }); + const fakeProps = (): PlantGridProps => ({ xy_swap: true, openfarm_slug: "beets", @@ -31,13 +35,7 @@ describe("", () => { it("renders", () => { const p = fakeProps(); const el = mount(); - // Upon load, there should be one button. - const previewButton = el.find("a.preview-button"); - expect(previewButton.text()).toContain("Preview"); - previewButton.simulate("click"); - expect(init).toHaveBeenCalledTimes(6); - - // After clicking PREVIEW, there should be two buttons. + expect(batchInitDirty).toHaveBeenCalledTimes(1); const cancel = el.find("a.cancel-button"); const save = el.find("a.save-button"); expect(cancel.text()).toContain("Cancel"); @@ -48,11 +46,7 @@ describe("", () => { it("renders update button", () => { const p = fakeProps(); const wrapper = mount(); - wrapper.setState({ autoPreview: false }); - const previewButton = wrapper.find("a.preview-button"); - expect(previewButton.text()).toContain("Preview"); - previewButton.simulate("click"); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); wrapper.setState({ offsetPacking: true }); const cancel = wrapper.find("a.cancel-button"); const update = wrapper.find("a.update-button"); @@ -90,41 +84,42 @@ describe("", () => { expect(stashGrid).toHaveBeenCalledWith(wrapper.state().gridId); }); - it("prevents creation of grids with > 100 plants", () => { + it(`prevents creation of grids with > ${MAX_N} plants`, () => { const props = fakeProps(); const wrapper = mount(); wrapper.setState({ grid: { ...wrapper.state().grid, - numPlantsH: 10, + numPlantsH: MAX_N / 10, numPlantsV: 11 } }); wrapper.instance().performPreview()(); expect(error).toHaveBeenCalledWith( - "Please make a grid with less than 100 plants"); + `Please make a grid with less than ${MAX_N} plants`); }); - it("prevents creation of grids with > 100 points", () => { + it(`prevents creation of grids with > ${MAX_N} points`, () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); wrapper.setState({ grid: { ...wrapper.state().grid, - numPlantsH: 10, + numPlantsH: MAX_N / 10, numPlantsV: 11 } }); wrapper.instance().performPreview()(); expect(error).toHaveBeenCalledWith( - "Please make a grid with less than 100 points"); + `Please make a grid with less than ${MAX_N} points`); }); it("doesn't perform preview", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ autoPreview: false, grid: { @@ -135,7 +130,7 @@ describe("", () => { }); wrapper.instance().performPreview()(); expect(error).not.toHaveBeenCalled(); - expect(init).not.toHaveBeenCalled(); + expect(batchInitDirty).not.toHaveBeenCalled(); }); it("performs preview", () => { @@ -150,16 +145,18 @@ describe("", () => { designer.cropHeightCurveId = 3; p.designer = designer; const wrapper = mount(); + jest.clearAllMocks(); wrapper.instance().performPreview()(); expect(error).not.toHaveBeenCalled(); - expect(init).toHaveBeenCalledTimes(6); - expect(init).toHaveBeenCalledWith("Point", expect.objectContaining({ - plant_stage: "planted", - planted_at: "2020-01-20T20:00:00.000Z", - water_curve_id: 1, - spread_curve_id: 2, - height_curve_id: 3, - })); + expect(batchInitDirty).toHaveBeenCalledTimes(1); + expect(batchInitDirty).toHaveBeenCalledWith("Point", + expect.arrayContaining([expect.objectContaining({ + plant_stage: "planted", + planted_at: "2020-01-20T20:00:00.000Z", + water_curve_id: 1, + spread_curve_id: 2, + height_curve_id: 3, + })])); }); it("discards unsaved changes", () => { @@ -175,6 +172,8 @@ describe("", () => { const wrapper = mount(); wrapper.instance().onChange("numPlantsH", 6); expect(wrapper.state().grid.numPlantsH).toEqual(6); + wrapper.instance().onChange("numPlantsH", 6); + expect(wrapper.state().grid.numPlantsH).toEqual(6); }); it("handles data changes: starting coordinates", () => { @@ -200,30 +199,33 @@ describe("", () => { it("toggles packing method on", () => { const p = fakeProps(); const wrapper = mount(); + jest.clearAllMocks(); expect(wrapper.state().offsetPacking).toBeFalsy(); wrapper.find('[title="toggle packing method"]') .first().simulate("click"); expect(wrapper.state().offsetPacking).toBeTruthy(); expect(wrapper.state().grid.spacingH).toEqual(217); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles packing method off", () => { const p = fakeProps(); const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ offsetPacking: true }); expect(wrapper.state().offsetPacking).toBeTruthy(); wrapper.find('[title="toggle packing method"]') .first().simulate("click"); expect(wrapper.state().offsetPacking).toBeFalsy(); expect(wrapper.state().grid.spacingH).toEqual(250); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles camera view on", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); expect(wrapper.state().cameraView).toBeFalsy(); wrapper.find('[title="show camera view area"]') .first().simulate("click"); @@ -232,13 +234,14 @@ describe("", () => { payload: wrapper.state().gridId, }); expect(wrapper.state().cameraView).toBeTruthy(); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles camera view off", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ cameraView: true }); wrapper.find('[title="show camera view area"]') .first().simulate("click"); @@ -247,35 +250,22 @@ describe("", () => { payload: undefined, }); expect(wrapper.state().cameraView).toBeFalsy(); - expect(init).toHaveBeenCalledTimes(6); - }); - - it("toggles auto-preview off", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.setState({ autoPreview: true }); - wrapper.find('[title="automatically update preview"]') - .first().simulate("click"); - expect(wrapper.state().autoPreview).toBeFalsy(); - }); - - it("toggles auto-preview on", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.setState({ autoPreview: false }); - wrapper.find('[title="automatically update preview"]') - .first().simulate("click"); - expect(wrapper.state().autoPreview).toBeTruthy(); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("collapses", () => { const p = fakeProps(); p.collapsible = true; const wrapper = mount(); + jest.clearAllMocks(); + expect(wrapper.state().isOpen).toBeFalsy(); + const chevronDown = wrapper.find("i").first(); + expect(chevronDown.hasClass("fa-chevron-down")).toBeTruthy(); + chevronDown.simulate("click"); expect(wrapper.state().isOpen).toBeTruthy(); - const chevron = wrapper.find("i").first(); - expect(chevron.hasClass("fa-chevron-up")).toBeTruthy(); - chevron.simulate("click"); + const chevronUp = wrapper.find("i").first(); + expect(chevronUp.hasClass("fa-chevron-up")).toBeTruthy(); + chevronUp.simulate("click"); expect(wrapper.state().isOpen).toBeFalsy(); }); }); diff --git a/frontend/plants/grid/__tests__/thunks_test.ts b/frontend/plants/grid/__tests__/thunks_test.ts index 8af4ec0f30..ab2d918d3e 100644 --- a/frontend/plants/grid/__tests__/thunks_test.ts +++ b/frontend/plants/grid/__tests__/thunks_test.ts @@ -1,7 +1,6 @@ const mockSaveAllReturnValue = { mock: "yep" }; jest.mock("../../../api/crud", () => ({ saveAll: jest.fn(() => mockSaveAllReturnValue), - destroy: jest.fn() })); import { saveGrid, stashGrid } from "../thunks"; @@ -10,7 +9,8 @@ import { } from "../../../__test_support__/resource_index_builder"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { fakeState } from "../../../__test_support__/fake_state"; -import { saveAll, destroy } from "../../../api/crud"; +import { saveAll } from "../../../api/crud"; +import { Actions } from "../../../constants"; const GRID_ID = "1234567"; const PLANT = fakePlant(); @@ -33,7 +33,11 @@ describe("stashGrid", () => { const thunk = stashGrid(GRID_ID); const state = fakeState(); state.resources = buildResourceIndex([PLANT]); - thunk(jest.fn, jest.fn(() => state)); - expect(destroy).toHaveBeenLastCalledWith(PLANT.uuid, true); + const dispatch = jest.fn(); + thunk(dispatch, jest.fn(() => state)); + expect(dispatch).toHaveBeenLastCalledWith({ + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: [PLANT], + }); }); }); diff --git a/frontend/plants/grid/grid_input.tsx b/frontend/plants/grid/grid_input.tsx index 5bc550599c..afeb82ea5d 100644 --- a/frontend/plants/grid/grid_input.tsx +++ b/frontend/plants/grid/grid_input.tsx @@ -24,7 +24,7 @@ export const getLabel = ( }; export function InputCell(props: InputCellProps) { - const { gridKey, onChange, grid, preview } = props; + const { gridKey, onChange, grid } = props; const [value, setValue] = React.useState("" + grid[gridKey]); return
{ const number = parseInt(value, 10); !isNaN(number) && onChange(gridKey, number); - isNaN(number) - ? setValue("" + grid[gridKey]) - : preview(); + isNaN(number) && setValue("" + grid[gridKey]); }} onChange={e => setValue(e.currentTarget.value)} />
; @@ -76,14 +74,12 @@ export function GridInput(props: GridInputProps) { xy_swap={props.xy_swap} gridKey={left} onChange={props.onChange} - preview={props.preview} grid={props.grid} /> )}
; diff --git a/frontend/plants/grid/interfaces.ts b/frontend/plants/grid/interfaces.ts index 73cd892ba6..21b2ffeb0b 100644 --- a/frontend/plants/grid/interfaces.ts +++ b/frontend/plants/grid/interfaces.ts @@ -52,7 +52,6 @@ interface GridInputPropsBase { grid: PlantGridData; xy_swap: boolean; onChange(key: PlantGridKey, value: number): void; - preview(): void; } export interface GridInputProps extends GridInputPropsBase { diff --git a/frontend/plants/grid/plant_grid.tsx b/frontend/plants/grid/plant_grid.tsx index c7e3fa44d0..4b74cf1851 100644 --- a/frontend/plants/grid/plant_grid.tsx +++ b/frontend/plants/grid/plant_grid.tsx @@ -5,7 +5,7 @@ import { PlantGridState, } from "./interfaces"; import { initPlantGrid } from "./generate_grid"; -import { init } from "../../api/crud"; +import { batchInitDirty } from "../../api/crud"; import { uuid } from "farmbot"; import { saveGrid, stashGrid } from "./thunks"; import { error, success } from "../../toast/toast"; @@ -17,6 +17,8 @@ import { Actions } from "../../constants"; import { round } from "lodash"; import { Collapse } from "@blueprintjs/core"; +export const MAX_N = 200; + export class PlantGrid extends React.Component { state: PlantGridState = { grid: this.initGridState, @@ -26,7 +28,7 @@ export class PlantGrid extends React.Component { cameraView: false, previous: "", autoPreview: true, - isOpen: true, + isOpen: false, }; get initGridState() { @@ -48,6 +50,7 @@ export class PlantGrid extends React.Component { } onChange = (key: PlantGridKey, val: number) => { + if (this.state.grid[key] == val) { return; } const grid = { ...this.state.grid, [key]: val }; ["startX", "startY"].includes(key) && this.props.dispatch({ @@ -79,27 +82,37 @@ export class PlantGrid extends React.Component { get outdated() { return this.getKey() != this.state.previous; } get dirty() { return this.state.status === "dirty"; } - componentDidUpdate = () => { - if (this.dirty && this.outdated) { - this.performPreview()(); - } - }; + componentDidMount() { + !this.props.collapsible && this.performPreview()(); + } - componentWillUnmount() { + unmount = () => { this.dirty && this.props.dispatch(stashGrid(this.state.gridId)); this.props.dispatch(showCameraViewPoints(undefined)); + }; + + componentWillUnmount() { + this.unmount(); } + consoleLog = (action: string, start: number) => + console.debug(`${action} plant grid in ${performance.now() - start} ms`); + performPreview = (force = false) => () => { if (!this.state.autoPreview && !force) { return; } + console.debug("performPreview"); + const startRevertPreview = performance.now(); this.revertPreview({ setStatus: false })(); - if (this.plantCount > 100) { - error(t("Please make a grid with less than 100 {{ itemType }}", - { itemType: this.props.openfarm_slug ? t("plants") : t("points") })); + this.consoleLog("Reverted", startRevertPreview); + if (this.plantCount > MAX_N) { + error(t("Please make a grid with less than {{ n }} {{ itemType }}", { + n: MAX_N, + itemType: this.props.openfarm_slug ? t("plants") : t("points"), + })); return; } - + const startInitPlantGrid = performance.now(); const plants = initPlantGrid({ grid: this.state.grid, openfarm_slug: this.props.openfarm_slug, @@ -111,13 +124,17 @@ export class PlantGrid extends React.Component { meta: this.props.meta, designer: this.props.designer, }); - plants.map(p => this.props.dispatch(init("Point", p))); + this.consoleLog("Generated", startInitPlantGrid); + const startDispatch = performance.now(); + this.props.dispatch(batchInitDirty("Point", plants)); + this.consoleLog("Dispatched", startDispatch); this.setState({ status: "dirty", previous: this.getKey() }); }; revertPreview = ({ setStatus }: { setStatus: boolean }) => () => this.props.dispatch(stashGrid(this.state.gridId)) - .then(() => setStatus && this.setState({ status: "clean" })); + .then(() => setStatus && + this.setState({ status: "clean" }, this.props.close)); saveGrid = () => this.props.dispatch(saveGrid(this.state.gridId)) @@ -137,10 +154,10 @@ export class PlantGrid extends React.Component { switch (this.state.status) { case "clean": return ; case "dirty": @@ -169,7 +186,10 @@ export class PlantGrid extends React.Component { return
{this.props.collapsible && this.setState({ isOpen: !this.state.isOpen })} />} + onClick={() => { + !this.state.isOpen ? this.performPreview()() : this.unmount(); + this.setState({ isOpen: !this.state.isOpen }); + }} />}

{t("Add Grid or Row")}

{ grid={this.state.grid} botPosition={this.props.botPosition} onChange={this.onChange} - onUseCurrentPosition={this.onUseCurrentPosition} - preview={this.performPreview()} /> + onUseCurrentPosition={this.onUseCurrentPosition} /> this.setState({ offsetPacking: !this.state.offsetPacking, @@ -200,17 +219,6 @@ export class PlantGrid extends React.Component { this.setState({ cameraView: !this.state.cameraView }, this.performPreview()); }} />} -
- - { - const enabled = this.state.autoPreview; - if (!enabled) { this.performPreview(true); } - this.setState({ autoPreview: !enabled }); - }} - title={t("automatically update preview")} /> -
; diff --git a/frontend/plants/grid/thunks.ts b/frontend/plants/grid/thunks.ts index bbb62749b0..ccd321d677 100644 --- a/frontend/plants/grid/thunks.ts +++ b/frontend/plants/grid/thunks.ts @@ -2,7 +2,8 @@ import { GetState } from "../../redux/interfaces"; import { ResourceIndex } from "../../resources/interfaces"; import { selectAllActivePoints } from "../../resources/selectors"; import { TaggedPoint } from "farmbot"; -import { destroy, saveAll } from "../../api/crud"; +import { saveAll } from "../../api/crud"; +import { Actions } from "../../constants"; const filterByGridId = (gridId: string) => (p: TaggedPoint) => p.body.meta["gridId"] === gridId; @@ -24,7 +25,10 @@ export function saveGrid(gridId: string) { export function stashGrid(gridId: string) { return function (dispatch: Function, getState: GetState) { const plants = findPlantByGridId(getState().resources.index, gridId); - const all = plants.map((x): Promise<{}> => dispatch(destroy(x.uuid, true))); - return Promise.all(all); + dispatch({ + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: plants, + }); + return Promise.all([]); }; } diff --git a/frontend/resources/__tests__/reducer_test.ts b/frontend/resources/__tests__/reducer_test.ts index cf29edb802..5eba8346d9 100644 --- a/frontend/resources/__tests__/reducer_test.ts +++ b/frontend/resources/__tests__/reducer_test.ts @@ -196,6 +196,17 @@ describe("resource reducer", () => { .map((kind: ResourceName) => testResourceDestroy(kind)); }); + it("destroys resources", () => { + const state = fakeState().resources; + const plant = fakePlant(); + const action = { + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: [plant], + }; + const newState = resourceReducer(state, action); + expect(newState.index.references[plant.uuid]).toEqual(undefined); + }); + it("toggles folder open state", () => { const folder = fakeFolder(); folder.body.id = 1; diff --git a/frontend/resources/reducer.ts b/frontend/resources/reducer.ts index cd7fd615fe..724600e5e2 100644 --- a/frontend/resources/reducer.ts +++ b/frontend/resources/reducer.ts @@ -159,6 +159,13 @@ export const resourceReducer = folderIndexer(payload, s.index); return s; }) + .add(Actions.BATCH_DESTROY_RESOURCE_OK, (s, { payload }) => { + payload.map(p => { + indexRemove(s.index, p); + folderIndexer(p, s.index); + }); + return s; + }) .add(Actions._RESOURCE_NO, (s, { payload }) => { merge(findByUuid(s.index, payload.uuid), payload); mutateSpecialStatus(payload.uuid, s.index, payload.statusBeforeError); diff --git a/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx b/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx index 8611e421ed..1bf30a723f 100644 --- a/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx +++ b/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx @@ -27,7 +27,7 @@ describe("", () => { it("select timezone", () => { const p = fakeProps(); render(); - const selector = screen.getByRole("combobox"); + const selector = screen.getByRole("button", { name: "UTC" }); fireEvent.click(selector); const item = screen.getByText("America/Los_Angeles"); fireEvent.click(item); diff --git a/frontend/settings/firmware/__tests__/board_type_test.tsx b/frontend/settings/firmware/__tests__/board_type_test.tsx index d6a399ba55..87165f385b 100644 --- a/frontend/settings/firmware/__tests__/board_type_test.tsx +++ b/frontend/settings/firmware/__tests__/board_type_test.tsx @@ -61,7 +61,8 @@ describe("", () => { const p = fakeProps(); p.firmwareHardware = "arduino"; render(); - const selection = screen.getByRole("combobox"); + const selection = + screen.getByRole("button", { name: "Arduino/RAMPS (Genesis v1.2)" }); fireEvent.click(selection); const item = screen.getByText("Farmduino (Genesis v1.3)"); fireEvent.click(item); @@ -74,7 +75,7 @@ describe("", () => { it("displays boards", () => { mockFeatureBoolean = false; render(); - const selection = screen.getByRole("combobox"); + const selection = screen.getByRole("button", { name: "None" }); fireEvent.click(selection); [ { label: "Farmduino (Genesis v1.7)", value: "farmduino_k17" }, @@ -95,7 +96,7 @@ describe("", () => { it("displays more boards", () => { mockFeatureBoolean = true; render(); - const selection = screen.getByRole("combobox"); + const selection = screen.getByRole("button", { name: "None" }); fireEvent.click(selection); expect(screen.getByText("Farmduino (Express v1.2)")).toBeInTheDocument(); expect(screen.getByText("Farmduino (Genesis v1.8)")).toBeInTheDocument(); diff --git a/frontend/three_d_garden/__tests__/components_test.tsx b/frontend/three_d_garden/__tests__/components_test.tsx index 598d135c2d..1982d730a5 100644 --- a/frontend/three_d_garden/__tests__/components_test.tsx +++ b/frontend/three_d_garden/__tests__/components_test.tsx @@ -11,6 +11,7 @@ import { Group, Mesh, MeshBasicMaterial, + MeshPhongMaterial, PointLight, SpotLight, } from "../components"; @@ -93,6 +94,17 @@ describe("", () => { }); }); +describe("", () => { + const fakeProps = (): ThreeElements["meshPhongMaterial"] => ({ + name: "material", + }); + + it("adds props", () => { + const wrapper = mount(); + expect(wrapper.props().name).toEqual("material"); + }); +}); + describe("", () => { const fakeProps = (): ThreeElements["spotLight"] => ({ visible: true, diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 832689c9e9..17f321c56c 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -90,6 +90,7 @@ import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; import { SpecialStatus } from "farmbot"; import { BufferGeometry } from "three"; +import { ActivePositionRef } from "../objects/pointer_objects"; describe("", () => { beforeEach(() => { @@ -114,6 +115,7 @@ describe("", () => { sensors: [], sensorReadings: [], showMoistureReadings: true, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("renders bed", () => { diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 55ef082b4f..550ac5c9ec 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -31,6 +31,7 @@ import { GetWebAppConfigValue } from "../../config_storage/actions"; import { DesignerState } from "../../farm_designer/interfaces"; import { useNavigate } from "react-router"; import { + ActivePositionRef, BillboardRef, ImageRef, PointerObjects, PointerPlantRef, RadiusRef, soilClick, soilPointerMove, @@ -113,6 +114,7 @@ export interface BedProps { showMoistureReadings: boolean; sensors: TaggedSensor[]; sensorReadings: TaggedSensorReading[]; + activePositionRef: ActivePositionRef; } export const Bed = (props: BedProps) => { @@ -217,6 +219,7 @@ export const Bed = (props: BedProps) => { imageRef, xCrosshairRef, yCrosshairRef, + activePositionRef: props.activePositionRef, getZ: props.getZ, })} castShadow={true} @@ -372,6 +375,7 @@ export const Bed = (props: BedProps) => { imageRef={imageRef} xCrosshairRef={xCrosshairRef} yCrosshairRef={yCrosshairRef} + activePositionRef={props.activePositionRef} config={props.config} addPlantProps={props.addPlantProps} mapPoints={props.mapPoints} />} diff --git a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx index 82a669e595..4ff122028c 100644 --- a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx +++ b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx @@ -9,6 +9,7 @@ jest.mock("../../../../screen_size", () => ({ import React from "react"; import { + ActivePositionRef, BillboardRef, ImageRef, PointerObjects, PointerObjectsProps, @@ -43,6 +44,7 @@ describe("", () => { imageRef: { current: { scale: new Vector3(0, 0, 0) } } as ImageRef, xCrosshairRef: { current: { position: new Vector3(0, 0, 0) } } as XCrosshairRef, yCrosshairRef: { current: { position: new Vector3(0, 0, 0) } } as YCrosshairRef, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("renders", () => { @@ -90,6 +92,7 @@ describe("soilPointerMove()", () => { imageRef: { current: { scale: { set: jest.fn() } } } as unknown as ImageRef, xCrosshairRef: { current: { position: { set: jest.fn() } } } as unknown as XCrosshairRef, yCrosshairRef: { current: { position: { set: jest.fn() } } } as unknown as YCrosshairRef, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("updates plant position", () => { diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index e9df500ad0..ec8b6b608a 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -1,13 +1,18 @@ import React from "react"; -import { Group } from "../../components"; -import { Billboard, Line, Image } from "@react-three/drei"; -import { findIcon } from "../../../crops/find"; +import { Group, MeshPhongMaterial } from "../../components"; +import { Billboard, Line, Image, Sphere } from "@react-three/drei"; +import { findCrop, findIcon } from "../../../crops/find"; import { Mode } from "../../../farm_designer/map/interfaces"; import { getMode, round, xyDistance } from "../../../farm_designer/map/util"; import { isMobile } from "../../../screen_size"; import { HOVER_OBJECT_MODES, DRAW_POINT_MODES, RenderOrder } from "../../constants"; import { - DrawnPoint, POINT_CYLINDER_SCALE_FACTOR, WEED_IMG_SIZE_FRACTION, + DrawnPoint, + getBoundsCenter, + getHalfSize, + outOfBoundsShaderModification, + POINT_CYLINDER_SCALE_FACTOR, + WEED_IMG_SIZE_FRACTION, } from "../../garden"; import { zero as zeroFunc, @@ -21,7 +26,7 @@ import { SpecialStatus, TaggedGenericPointer } from "farmbot"; import { AddPlantProps } from "../bed"; import { DEFAULT_PLANT_RADIUS } from "../../../farm_designer/plant"; import { isUndefined, round as mathRound } from "lodash"; -import { Mesh as MeshType, Group as GroupType } from "three"; +import { Mesh as MeshType, Group as GroupType, Color } from "three"; import { Path } from "../../../internal_urls"; import { ThreeEvent } from "@react-three/fiber"; import { dropPlant } from "../../../farm_designer/map/layers/plants/plant_actions"; @@ -38,6 +43,7 @@ export type BillboardRef = React.RefObject; export type ImageRef = React.RefObject; export type XCrosshairRef = React.RefObject; export type YCrosshairRef = React.RefObject; +export type ActivePositionRef = React.RefObject<{ x: number, y: number } | null>; interface AllRefs { pointerPlantRef: PointerPlantRef; @@ -53,6 +59,7 @@ export interface PointerObjectsProps extends AllRefs { config: Config; mapPoints: TaggedGenericPointer[]; addPlantProps: AddPlantProps; + activePositionRef: ActivePositionRef; } export const PointerObjects = (props: PointerObjectsProps) => { @@ -71,7 +78,10 @@ export const PointerObjects = (props: PointerObjectsProps) => { const gridPreview = mapPoints .filter(p => p.specialStatus == SpecialStatus.DIRTY && p.body.meta.gridId) .length > 0; - + // eslint-disable-next-line react-hooks/exhaustive-deps + const boundsCenter = React.useMemo(getBoundsCenter(config), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const halfSize = React.useMemo(getHalfSize(config), []); return HOVER_OBJECT_MODES.includes(getMode()) && !isMobile() && @@ -115,14 +125,30 @@ export const PointerObjects = (props: PointerObjectsProps) => { designer={addPlantProps.designer} usePosition={settingRadius} />} {getMode() == Mode.clickToAdd && - - - } + + + + + + { + shader.uniforms.uBoundsCenter = { value: boundsCenter }; + shader.uniforms.uHalfSize = { value: halfSize }; + shader.uniforms.uInside = { value: new Color("white") }; + shader.uniforms.uOutside = { value: new Color("red") }; + outOfBoundsShaderModification(shader); + }} + depthWrite={false} /> + + } ; @@ -192,6 +218,7 @@ export interface SoilPointerMoveProps extends AllRefs { config: Config; addPlantProps: AddPlantProps; getZ(x: number, y: number): number; + activePositionRef: ActivePositionRef; } export const soilPointerMove = (props: SoilPointerMoveProps) => @@ -200,7 +227,7 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => config, addPlantProps, pointerPlantRef, radiusRef, torusRef, billboardRef, imageRef, - xCrosshairRef, yCrosshairRef, + xCrosshairRef, yCrosshairRef, activePositionRef, } = props; const getGardenPosition = getGardenPositionFunc(config); const get3DPosition = get3DPositionFunc(config); @@ -213,6 +240,7 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => const z = zZero(config) + props.getZ(gardenPosition.x, gardenPosition.y); xCrosshairRef.current?.position.set(0, y, z); yCrosshairRef.current?.position.set(x, 0, z); + activePositionRef.current = { x, y }; if (getMode() == Mode.clickToAdd) { pointerPlantRef.current.position.set(x, y, z); } diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 4227f54771..ee176d7dda 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -4,15 +4,15 @@ interface MockRef { position: { z: number; }; } | undefined; } -const mockRef: MockRef = { +const mockRef = (): MockRef => ({ current: { scale: { set: jest.fn() }, position: { z: 0 }, } -}; +}); jest.mock("react", () => ({ ...jest.requireActual("react"), - useRef: () => mockRef, + useRef: mockRef, })); import React from "react"; @@ -27,10 +27,17 @@ import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; describe("", () => { + beforeEach(() => { + location.pathname = Path.mock(Path.designer()); + }); + const fakeProps = (): ThreeDPlantProps => { const config = clone(INITIAL); const plant = fakePlant(); plant.body.name = "Beet"; + plant.body.id = 1; + const otherPlant = fakePlant(); + otherPlant.body.id = 2; return { plant: convertPlants(config, [plant])[0], i: 0, @@ -38,6 +45,8 @@ describe("", () => { hoveredPlant: undefined, visible: true, getZ: () => 0, + activePositionRef: { current: { x: 0, y: 0 } }, + plants: convertPlants(config, [plant, otherPlant]), }; }; @@ -66,11 +75,34 @@ describe("", () => { p.config.labelsOnHover = false; p.labelOnly = false; p.config.light = false; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); + it("renders spread", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + const p = fakeProps(); + p.spreadVisible = true; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + + it("renders spread: edit plant mode", () => { + location.pathname = Path.mock(Path.plants("1")); + const p = fakeProps(); + p.spreadVisible = false; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + + it("renders spread: edit plant mode without plant", () => { + location.pathname = Path.mock(Path.plants("999999")); + const p = fakeProps(); + p.spreadVisible = false; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + it("renders plant: not size animated", () => { const p = fakeProps(); p.config.labels = false; @@ -79,7 +111,6 @@ describe("", () => { p.config.light = false; p.config.animateSeasons = true; p.startTimeRef = undefined; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); @@ -92,7 +123,6 @@ describe("", () => { p.config.light = false; p.config.animateSeasons = true; p.startTimeRef = { current: 0 }; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); @@ -103,7 +133,6 @@ describe("", () => { p.config.labelsOnHover = false; p.labelOnly = false; p.config.light = true; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index a72abf5487..a39e1fab8b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -1,18 +1,36 @@ import React from "react"; import { Config } from "../config"; import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; -import { Billboard, Plane, useTexture } from "@react-three/drei"; -import { Vector3, Mesh, Group as GroupType } from "three"; -import { threeSpace, zZero as zZeroFunc } from "../helpers"; +import { Billboard, Plane, Sphere, useTexture } from "@react-three/drei"; +import { + Vector3, + Mesh, + Group as GroupType, + Color, + WebGLProgramParametersWithUniforms, +} from "three"; +import { + getGardenPositionFunc, + threeSpace, + zZero, + zZero as zZeroFunc, +} from "../helpers"; import { Text } from "../elements"; import { isUndefined } 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"; +import { getMode, round } from "../../farm_designer/map/util"; import { ThreeElements, useFrame } from "@react-three/fiber"; import { getSizeAtTime } from "../../promo/plants"; import { FixedNormalMaterial } from "./fixed_normal_material"; +import { Group, MeshPhongMaterial } from "../components"; +import { + getSpreadOverlap, getSpreadRadii, +} from "../../farm_designer/map/layers/spread/spread_overlap_helper"; +import { ActivePositionRef } from "../bed/objects/pointer_objects"; +import { Mode } from "../../farm_designer/map/interfaces"; +import { findCrop } from "../../crops/find"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -34,8 +52,11 @@ export interface ThreeDPlantProps { hoveredPlant: number | undefined; dispatch?: Function; visible?: boolean; + spreadVisible?: boolean; getZ(x: number, y: number): number; startTimeRef?: React.RefObject; + activePositionRef: ActivePositionRef; + plants: ThreeDGardenPlant[]; } export const ThreeDPlant = (props: ThreeDPlantProps) => { @@ -57,19 +78,19 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { getPlantZ(plant.size), )}> {labelOnly - ? - {plant.label} - - : + : { ; }; +interface LabelPartProps { + visible: boolean; + plant: ThreeDGardenPlant; +} + +const LabelPart = (props: LabelPartProps) => + + {props.plant.label} + ; + +interface PlantPartProps extends CustomImageProps { + spreadVisible: boolean; + config: Config; + activePositionRef: ActivePositionRef; + plants: ThreeDGardenPlant[]; +} + +const PlantPart = (props: PlantPartProps) => { + const { config } = props; + // eslint-disable-next-line react-hooks/exhaustive-deps + const boundsCenter = React.useMemo(getBoundsCenter(config), []); + const editPlantMode = + Path.getSlug(Path.designer()) == "plants" && Path.lastChunkIsNum(); + const plantId = parseInt(Path.getSlug(Path.plants())); + const currentPlant = + props.plants.filter(p => p.id == plantId)[0] as ThreeDGardenPlant | undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + const halfSize = React.useMemo(getHalfSize(config), []); + const spreadRadii = getSpreadRadii({ + activeDragSpread: editPlantMode + ? currentPlant?.spread + : findCrop(Path.getCropSlug()).spread, + inactiveSpread: props.plant.spread, + radius: props.plant.size / 2, + }); + + const rgb = React.useMemo(() => ({ value: [0, 1, 0] }), []); + useFrame(() => { + const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; + const activePointer = getGardenPositionFunc(config)(worldPos); + const active = editPlantMode + ? { + x: currentPlant?.x || -10000, + y: currentPlant?.y || -10000, + } + : { + x: activePointer.x + config.bedXOffset, + y: activePointer.y + config.bedYOffset, + }; + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY: { + x: round(active.x), + y: round(active.y), + z: 0, + }, + plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 }, + }); + const color = (props.plant.id && (plantId != props.plant.id)) + ? overlap.color.rgb + : [1, 1, 1]; + const clickToAddMode = getMode() == Mode.clickToAdd; + rgb.value = (clickToAddMode || editPlantMode) ? color : [0, 1, 0]; + }); + return + + {(props.spreadVisible || !props.plant.id || editPlantMode) && + + { + shader.uniforms.uBoundsCenter = { value: boundsCenter }; + shader.uniforms.uHalfSize = { value: halfSize }; + shader.uniforms.uInside = rgb; + shader.uniforms.uOutside = { value: new Color("red") }; + outOfBoundsShaderModification(shader); + }} + depthWrite={false} /> + } + ; +}; + +export const getBoundsCenter = (config: Config) => () => + new Vector3( + 0, + 0, + -10000 + zZero(config), + ); + +export const getHalfSize = (config: Config) => () => new Vector3( + config.bedLengthOuter / 2 - 300, + config.bedWidthOuter / 2 - config.bedWallThickness, + 10000, +); + +export const outOfBoundsShaderModification = + (shader: WebGLProgramParametersWithUniforms) => { + shader.vertexShader = shader.vertexShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition;`, + ).replace( + "#include ", + `#include + vWorldPosition = worldPosition.xyz;`); + shader.fragmentShader = shader.fragmentShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition; + uniform vec3 uBoundsCenter; + uniform vec3 uHalfSize; + uniform vec3 uInside; + uniform vec3 uOutside;`, + ).replace( + "#include ", + `#include + vec3 p = vWorldPosition - uBoundsCenter; + bool inside = + p.x > -uHalfSize.x && + abs(p.y) <= uHalfSize.y && + abs(p.z) <= uHalfSize.z; + diffuseColor.rgb = mix(uOutside, uInside, float(inside)); + `, + ); + }; + type MeshProps = ThreeElements["mesh"]; interface CustomImageProps extends MeshProps { url: string; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index ac60315c65..a14f5b209e 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -90,9 +90,10 @@ export const WeedBase = (props: WeedBaseProps) => { scale={weedSize} renderOrder={RenderOrder.weedSpheres} args={[1, 32, 32]} - position={[0, 0, 0]}> + position={[0, 0, iconSize / 2]}> diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 2f73b930a9..5c665a212a 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -104,6 +104,7 @@ export const GardenModel = (props: GardenModelProps) => { const showPoints = config.showSoilPoints || !!addPlantProps?.getConfigValue(BooleanSetting.show_points); const showWeeds = !!addPlantProps?.getConfigValue(BooleanSetting.show_weeds); + const showSpread = !!addPlantProps?.getConfigValue(BooleanSetting.show_spread); const soilPoints = filterSoilPoints({ points: props.mapPoints, config }); const soilSurface = React.useMemo(() => @@ -121,6 +122,8 @@ export const GardenModel = (props: GardenModelProps) => { // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const activePositionRef = React.useRef<{ x: number, y: number }>(null); // eslint-disable-next-line no-null/no-null return { showMoistureReadings={showMoistureReadings} sensors={props.sensors || []} sensorReadings={props.sensorReadings || []} + activePositionRef={activePositionRef} addPlantProps={addPlantProps} /> {showMoistureMap && props.config.moistureDebug && { {threeDPlants.map((plant, i) => )} { {threeDPlants.map((plant, i) => )} diff --git a/local_setup_instructions.sh b/local_setup_instructions.sh index f523dc8a26..40e361181a 100644 --- a/local_setup_instructions.sh +++ b/local_setup_instructions.sh @@ -55,6 +55,7 @@ nano .env # Install project dependencies # ============================ +sudo docker compose build web # Install the correct version of bundler for the project sudo docker compose run web gem install bundler # Install application specific Ruby dependencies @@ -65,7 +66,7 @@ sudo docker compose run web npm install sudo docker compose run web bundle exec rails db:create db:migrate # Generate a set of *.pem files for data encryption # ⚠ SKIP THIS STEP IF UPGRADING! -sudo docker compose run web rake keys:generate +sudo docker compose run web bundle exec rake keys:generate # Run the server! 🌱 # ================== @@ -91,7 +92,7 @@ sudo docker compose up # Create the database for the app to use sudo docker compose run -e RAILS_ENV=test web bundle exec rails db:setup # Run the tests in the "test" RAILS_ENV -sudo docker compose run -e RAILS_ENV=test web rspec spec +sudo docker compose run -e RAILS_ENV=test web bundle exec rspec spec # Run user-interface unit tests (requires a large amount of RAM) sudo docker compose run web npm run test @@ -138,6 +139,7 @@ sudo docker compose run web npm run test sudo rm -rf node_modules/ # Download the latest version of the web app git pull https://github.com/FarmBot/Farmbot-Web-App.git main + sudo docker compose build web # Install Ruby gems sudo docker compose run web gem install bundler sudo docker compose run web bundle install @@ -157,9 +159,9 @@ sudo docker compose run web npm run test exit # --- end db container shell commands --- # Migrate the database - sudo docker compose run web rails db:migrate + sudo docker compose run web bundle exec rails db:migrate # Verify that parcel builds successfully - sudo docker compose run web rake assets:precompile + sudo docker compose run web bundle exec rake assets:precompile # Run the server sudo docker compose up # === END OPTIONAL UPGRADES === diff --git a/package.json b/package.json index 2a603cb7d7..ca624a9159 100644 --- a/package.json +++ b/package.json @@ -37,42 +37,42 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "6.3.2", - "@blueprintjs/select": "6.0.6", + "@blueprintjs/core": "6.4.1", + "@blueprintjs/select": "6.0.10", "@monaco-editor/react": "4.7.0", - "@parcel/transformer-sass": "2.16.0", - "@parcel/transformer-typescript-tsc": "2.16.0", + "@parcel/transformer-sass": "2.16.3", + "@parcel/transformer-typescript-tsc": "2.16.3", "@react-spring/three": "10.0.3", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", "@rollbar/react": "1.0.0", - "@types/lodash": "4.17.20", + "@types/lodash": "4.17.21", "@types/markdown-it": "14.1.2", - "@types/node": "24.9.2", + "@types/node": "25.0.0", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.2", + "@types/react": "19.2.7", "@types/react-color": "3.0.13", - "@types/react-dom": "19.2.2", + "@types/react-dom": "19.2.3", "@types/three": "0.181.0", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", - "axios": "1.13.1", - "bowser": "2.12.1", + "axios": "1.13.2", + "bowser": "2.13.1", "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", "farmbot": "15.9.3", "fengari": "0.1.4", "fengari-web": "0.1.4", - "i18next": "25.6.0", + "i18next": "25.7.2", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", - "monaco-editor": "0.54.0", + "monaco-editor": "0.55.1", "mqtt": "5.14.1", - "npm": "11.6.2", - "parcel": "2.16.0", + "npm": "11.7.0", + "parcel": "2.16.3", "process": "0.11.10", "promise-timeout": "1.3.0", "punycode": "1.4.1", @@ -81,14 +81,14 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.9.5", + "react-router": "7.10.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", "rollbar": "2.26.5", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.181.0", + "three": "0.182.0", "typescript": "5.9.3", "url": "0.11.4" }, @@ -101,7 +101,7 @@ "@types/delaunator": "5.0.3", "@types/enzyme": "3.10.12", "@types/jest": "30.0.0", - "@types/readable-stream": "4.0.22", + "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", "@typescript-eslint/eslint-plugin": "7.15.0", "@typescript-eslint/parser": "7.15.0", @@ -110,7 +110,7 @@ "eslint": "8.57.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jest": "29.0.1", + "eslint-plugin-jest": "29.2.1", "eslint-plugin-no-null": "1.0.2", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", @@ -127,9 +127,9 @@ "raf": "3.4.1", "react-addons-test-utils": "15.6.2", "react-test-renderer": "18.3.1", - "sass": "1.93.3", + "sass": "1.95.1", "sass-lint": "1.13.1", - "ts-jest": "29.4.5", + "ts-jest": "29.4.6", "tslint": "5.20.1" } }