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"
}
}