From 4497ab938daf907b431d9d73cb727b239c708212 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 5 Feb 2025 13:04:08 -0800 Subject: [PATCH 01/15] SequencesController#index --- app/controllers/api/sequences_controller.rb | 34 ++++++++++++++++++--- app/models/sequence.rb | 2 ++ app/mutations/sequences/show.rb | 14 +++++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/sequences_controller.rb b/app/controllers/api/sequences_controller.rb index 640a82a0fb..5374248bbe 100644 --- a/app/controllers/api/sequences_controller.rb +++ b/app/controllers/api/sequences_controller.rb @@ -3,9 +3,33 @@ class SequencesController < Api::AbstractController before_action :clean_expired_farm_events, only: [:destroy] def index - render json: sequences - .to_a - .map { |s| Sequences::Show.run!(sequence: s) } + # Add performance logging + result = nil + count = 0 + queries = 0 + + # Track number of SQL queries + ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + queries += 1 + end + + # Measure execution time + time = Benchmark.measure do + result = sequences + .includes(:sequence_publication, :sequence_version) + .to_a + .map { |s| Sequences::Show.run!(sequence: s) } + count = result.length + end + + # Log the results + Rails.logger.info "=== Performance Stats ===" + Rails.logger.info "Processed #{count} sequences" + Rails.logger.info "Total SQL queries: #{queries}" + Rails.logger.info "Total time: #{time.real.round(2)} seconds" + Rails.logger.info "=======================" + + render json: result end def show @@ -66,7 +90,9 @@ def sequence_params end def sequences - @sequences ||= Sequence.with_usage_reports.where(device: current_device) + @sequences ||= Sequence + .with_usage_reports + .where(device: current_device) end def sequence diff --git a/app/models/sequence.rb b/app/models/sequence.rb index 70fc89a7d2..188b957b3b 100644 --- a/app/models/sequence.rb +++ b/app/models/sequence.rb @@ -23,6 +23,8 @@ class Sequence < ApplicationRecord has_many :regimen_items has_many :primary_nodes, dependent: :destroy has_many :edge_nodes, dependent: :destroy + has_one :sequence_publication, foreign_key: :author_sequence_id + belongs_to :sequence_version, optional: true # allowable label colors for the frontend. [:name, :kind].each { |n| validates n, presence: true } diff --git a/app/mutations/sequences/show.rb b/app/mutations/sequences/show.rb index 20df4c6d7b..be8c22c5b7 100644 --- a/app/mutations/sequences/show.rb +++ b/app/mutations/sequences/show.rb @@ -63,7 +63,11 @@ def copyright end def sequence_publication - @sequence_publication ||= SequencePublication.find_by(author_sequence_id: sequence.id) + # Cache the association if it's already loaded + return @sequence_publication if defined?(@sequence_publication) + @sequence_publication = sequence.association(:sequence_publication).loaded? ? + sequence.sequence_publication : + SequencePublication.find_by(author_sequence_id: sequence.id) end def description @@ -71,7 +75,11 @@ def description end def sequence_version - @sequence_version ||= SequenceVersion.find_by(id: sequence_version_id) + # Cache the association if it's already loaded + return @sequence_version if defined?(@sequence_version) + @sequence_version = sequence.association(:sequence_version).loaded? ? + sequence.sequence_version : + SequenceVersion.find_by(id: sequence_version_id) end def use_upstream_version? @@ -90,7 +98,7 @@ def available_version_ids # If it is not published, don't show anything to the author. # If it IS published, show the versions to the author. if is_owner? - return sequence_publication.sequence_versions.pluck(:id) + return sequence_publication&.sequence_versions&.pluck(:id) || [] end # Second attempt: From 907a478bb54a2055e42764b1d67b579b262d05d7 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 5 Feb 2025 13:05:08 -0800 Subject: [PATCH 02/15] remove performance logging --- app/controllers/api/sequences_controller.rb | 31 +++------------------ 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/app/controllers/api/sequences_controller.rb b/app/controllers/api/sequences_controller.rb index 5374248bbe..b2d8e9a13b 100644 --- a/app/controllers/api/sequences_controller.rb +++ b/app/controllers/api/sequences_controller.rb @@ -3,33 +3,10 @@ class SequencesController < Api::AbstractController before_action :clean_expired_farm_events, only: [:destroy] def index - # Add performance logging - result = nil - count = 0 - queries = 0 - - # Track number of SQL queries - ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| - queries += 1 - end - - # Measure execution time - time = Benchmark.measure do - result = sequences - .includes(:sequence_publication, :sequence_version) - .to_a - .map { |s| Sequences::Show.run!(sequence: s) } - count = result.length - end - - # Log the results - Rails.logger.info "=== Performance Stats ===" - Rails.logger.info "Processed #{count} sequences" - Rails.logger.info "Total SQL queries: #{queries}" - Rails.logger.info "Total time: #{time.real.round(2)} seconds" - Rails.logger.info "=======================" - - render json: result + render json: sequences + .includes(:sequence_publication, :sequence_version) + .to_a + .map { |s| Sequences::Show.run!(sequence: s) } end def show From da928e6fde233ebefd59cfa1ddb59ef9e4135fd8 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 5 Feb 2025 14:50:33 -0800 Subject: [PATCH 03/15] logs controller improvement --- app/controllers/api/logs_controller.rb | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/logs_controller.rb b/app/controllers/api/logs_controller.rb index a04e64edc5..86d1973ace 100644 --- a/app/controllers/api/logs_controller.rb +++ b/app/controllers/api/logs_controller.rb @@ -14,19 +14,27 @@ class LogsController < Api::AbstractController def search conf = current_device.web_app_config mt = CeleryScriptSettingsBag::ALLOWED_MESSAGE_TYPES - query = mt - .map { |x| "(type = '#{x}' AND verbosity <= ?)" } - .join(" OR ") - conditions = mt.map { |x| "#{x}_log" }.map { |x| conf.send(x) } - args_ = conditions.unshift(query) limit = current_device.max_log_count || Device::DEFAULT_MAX_LOGS - render json: current_device - .logs - .order(created_at: :desc) - .where(*args_) - .limit(limit) - .where(search_params) + # Build conditions once + type_conditions = mt.map.with_index do |type, i| + verbosity = conf.send("#{type}_log") + ["(type = ? AND verbosity <= ?)", type, verbosity] + end + + # Combine conditions efficiently + where_clause = type_conditions.map(&:first).join(" OR ") + where_values = type_conditions.flat_map { |c| c[1..-1] } + + # Single query with all conditions + logs = current_device + .logs + .order(created_at: :desc) + .limit(limit) + .where(where_clause, *where_values) + .where(search_params) + + render json: logs end def create From f2e669245b8847881eee9444afc3a9f34690396e Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 5 Feb 2025 15:15:27 -0800 Subject: [PATCH 04/15] trim_excess_sensor_readings in the background --- .../api/sensor_readings_controller.rb | 11 ++--------- app/models/device.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/sensor_readings_controller.rb b/app/controllers/api/sensor_readings_controller.rb index cd1f8b9b48..7e6f050e40 100644 --- a/app/controllers/api/sensor_readings_controller.rb +++ b/app/controllers/api/sensor_readings_controller.rb @@ -1,6 +1,5 @@ module Api class SensorReadingsController < Api::AbstractController - LIMIT = 2500 before_action :clean_old def create @@ -23,20 +22,14 @@ def destroy private def clean_old - if current_device.sensor_readings.count > LIMIT - current_device - .sensor_readings - .where - .not(id: readings.pluck(:id)) - .delete_all - end + current_device.delay.trim_excess_sensor_readings end def readings @readings ||= SensorReading .where(device: current_device) .order(created_at: :desc) - .limit(LIMIT) + .limit(Device::DEFAULT_MAX_SENSOR_READINGS) end def reading diff --git a/app/models/device.rb b/app/models/device.rb index a7830f3e70..05f708f262 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -4,6 +4,7 @@ class Device < ApplicationRecord DEFAULT_MAX_IMAGES = 100 DEFAULT_MAX_LOGS = 1000 DEFAULT_MAX_TELEMETRY = 300 + DEFAULT_MAX_SENSOR_READINGS = 2500 DEFAULT_MAX_LOG_AGE_IN_DAYS = 60 DEFAULT_MAX_SEQUENCE_COUNT = 75 DEFAULT_MAX_SEQUENCE_LENGTH = 30 @@ -120,6 +121,24 @@ def trim_excess_telemetry excess_telemetry.delete_all end + # Give the user back the amount of sensor readings they are allowed to view. + def limited_sensor_readings_list + sensor_readings + .order(created_at: :desc) + .limit(DEFAULT_MAX_SENSOR_READINGS) + end + + def excess_sensor_readings + sensor_readings + .where + .not(id: limited_sensor_readings_list.pluck(:id)) + .where(device_id: self.id) + end + + def trim_excess_sensor_readings + excess_sensor_readings.delete_all + end + def self.current RequestStore.store[:device] end From e5edf837292b669bb743278653327206584bebd7 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Wed, 5 Feb 2025 17:31:22 -0800 Subject: [PATCH 05/15] update sensor reading trim test --- spec/controllers/api/sensor_readings/controller_spec.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/controllers/api/sensor_readings/controller_spec.rb b/spec/controllers/api/sensor_readings/controller_spec.rb index 7f544917c1..af51c0400e 100644 --- a/spec/controllers/api/sensor_readings/controller_spec.rb +++ b/spec/controllers/api/sensor_readings/controller_spec.rb @@ -106,8 +106,8 @@ expect(response.status).to eq(401) end - it "cleans up excess logs" do - const_reassign(Api::SensorReadingsController, :LIMIT, 5) + it "cleans up excess sensor readings" do + const_reassign(Device, :DEFAULT_MAX_SENSOR_READINGS, 5) sign_in user 10.times do |n| FactoryBot.create(:sensor_reading, @@ -116,9 +116,10 @@ end expect(user.device.sensor_readings.count).to eq(10) get :index, params: { format: :json } + Delayed::Worker.new.work_off expect(json.count).to eq(5) expect(user.device.sensor_readings.count).to eq(5) - const_reassign(Api::SensorReadingsController, :LIMIT, 5000) + const_reassign(Device, :DEFAULT_MAX_SENSOR_READINGS, 2500) first = (json.first[:created_at]) last = (json.last[:created_at]) expect(first).to be > last From 7d2635c52fcce4e7a7f5f9e9e9e2f736c6514e33 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 2 Apr 2025 09:40:42 -0700 Subject: [PATCH 06/15] fix settings link bugs --- .../__test_support__/additional_mocks.tsx | 1 + .../map/easter_eggs/__tests__/bugs_test.tsx | 8 +- .../farm_designer/map/easter_eggs/bugs.tsx | 26 +- frontend/settings/__tests__/index_test.tsx | 21 +- .../__tests__/maybe_highlight_test.tsx | 58 +--- .../__tests__/change_password_test.tsx | 1 + .../fbos_settings/os_update_button.tsx | 3 +- frontend/settings/index.tsx | 312 +++++++++--------- frontend/settings/maybe_highlight.tsx | 158 ++++----- frontend/three_d_garden/index.tsx | 6 +- 10 files changed, 267 insertions(+), 327 deletions(-) diff --git a/frontend/__test_support__/additional_mocks.tsx b/frontend/__test_support__/additional_mocks.tsx index 43577a2752..0b0bd69bea 100644 --- a/frontend/__test_support__/additional_mocks.tsx +++ b/frontend/__test_support__/additional_mocks.tsx @@ -47,6 +47,7 @@ jest.mock("react-router", () => ({ Route: jest.fn(({ children }) =>
{children}
), Routes: jest.fn(({ children }) =>
{children}
), useNavigate: () => mockNavigate, + useLocation: () => window.location, Navigate: ({ to }: { to: string }) =>
{mockNavigate(to)}
, Outlet: jest.fn(() =>
), })); diff --git a/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx b/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx index 87f0c9dc8c..2a5e018783 100644 --- a/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx +++ b/frontend/farm_designer/map/easter_eggs/__tests__/bugs_test.tsx @@ -6,7 +6,7 @@ import React from "react"; import { shallow, mount } from "enzyme"; import { Bugs, BugsProps, showBugResetButton, showBugs, resetBugs, BugsControls, - ExtraSettings, + BugsSettings, } from "../bugs"; import { EggKeys, setEggStatus, getEggStatus } from "../status"; import { range } from "lodash"; @@ -112,10 +112,10 @@ describe("", () => { }); }); -describe("", () => { +describe("", () => { it("toggles setting on", () => { localStorage.setItem(EggKeys.BRING_ON_THE_BUGS, ""); - const wrapper = mount(
{ExtraSettings("surprise")}
); + const wrapper = mount(); expect(wrapper.text().toLowerCase()).toContain("bug"); wrapper.find("button").last().simulate("click"); expect(localStorage.getItem(EggKeys.BRING_ON_THE_BUGS)).toEqual("true"); @@ -123,7 +123,7 @@ describe("", () => { it("toggles setting off", () => { localStorage.setItem(EggKeys.BRING_ON_THE_BUGS, "true"); - const wrapper = mount(
{ExtraSettings("surprise")}
); + const wrapper = mount(); expect(wrapper.text().toLowerCase()).toContain("bug"); wrapper.find("button").last().simulate("click"); expect(localStorage.getItem(EggKeys.BRING_ON_THE_BUGS)).toEqual(""); diff --git a/frontend/farm_designer/map/easter_eggs/bugs.tsx b/frontend/farm_designer/map/easter_eggs/bugs.tsx index 98f7359796..cd0698c762 100644 --- a/frontend/farm_designer/map/easter_eggs/bugs.tsx +++ b/frontend/farm_designer/map/easter_eggs/bugs.tsx @@ -6,7 +6,6 @@ import { getEggStatus, setEggStatus, EggKeys } from "./status"; import { t } from "../../../i18next_wrapper"; import { Row, ToggleButton } from "../../../ui"; import { BUGS, FilePath, Bug as BugSlug } from "../../../internal_urls"; -import { showByEveryTerm } from "../../../settings"; export interface BugsProps { mapTransformProps: MapTransformProps; @@ -133,19 +132,28 @@ export const BugsControls = () =>
:
; -const Setting = (title: string, key: string, value: string) => { - const on = localStorage.getItem(key) == value; +interface SettingProps { + title: string; + storageKey: string; + value: string; +} + +const Setting = (props: SettingProps) => { + const { title, storageKey, value } = props; + const on = localStorage.getItem(storageKey) == value; return localStorage.setItem(key, on ? "" : value)} /> + toggleAction={() => localStorage.setItem(storageKey, on ? "" : value)} /> ; }; -export const ExtraSettings = (searchTerm: string) => { - return showByEveryTerm("surprise", searchTerm) && -
- {Setting("Bug Attack", EggKeys.BRING_ON_THE_BUGS, "true")} -
; +export const BugsSettings = () => { + return
+ +
; }; diff --git a/frontend/settings/__tests__/index_test.tsx b/frontend/settings/__tests__/index_test.tsx index c96351b3c7..41b7a527b6 100644 --- a/frontend/settings/__tests__/index_test.tsx +++ b/frontend/settings/__tests__/index_test.tsx @@ -94,16 +94,6 @@ describe("", () => { expect(maybeOpenPanel).toHaveBeenCalled(); }); - it("unmounts", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.unmount(); - expect(p.dispatch).toHaveBeenCalledWith({ - type: Actions.SET_SETTINGS_SEARCH_TERM, - payload: "", - }); - }); - it("sets search term", () => { location.search = "?search=search"; const p = fakeProps(); @@ -118,9 +108,7 @@ describe("", () => { location.search = "?search=search"; location.pathname = "path"; const p = fakeProps(); - const wrapper = shallow(); - const navigate = jest.fn(); - wrapper.instance().navigate = navigate; + const wrapper = shallow(); wrapper.find(SearchField).simulate("change", "setting"); expect(p.dispatch).toHaveBeenCalledWith({ type: Actions.BULK_TOGGLE_SETTINGS_PANEL, @@ -130,7 +118,7 @@ describe("", () => { type: Actions.SET_SETTINGS_SEARCH_TERM, payload: "setting", }); - expect(navigate).toHaveBeenCalledWith("path"); + expect(mockNavigate).toHaveBeenCalledWith("path"); }); it("fetches firmware_hardware", () => { @@ -238,7 +226,7 @@ describe("", () => { }); it("renders surprise", () => { - mockHighlightName = "surprise"; + location.search = "?only=surprise"; const p = fakeProps(); p.searchTerm = "surprise"; const wrapper = mount(); @@ -247,13 +235,12 @@ describe("", () => { it("cancels setting isolation", () => { location.search = "?only=setting"; - location.assign = jest.fn(); location.pathname = "path"; const p = fakeProps(); p.searchTerm = ""; const wrapper = mount(); clickButton(wrapper, 1, "cancel"); - expect(location.assign).toHaveBeenCalledWith("path"); + expect(mockNavigate).toHaveBeenCalledWith("path"); }); it("renders change ownership form", () => { diff --git a/frontend/settings/__tests__/maybe_highlight_test.tsx b/frontend/settings/__tests__/maybe_highlight_test.tsx index 663b0c1511..78b293139d 100644 --- a/frontend/settings/__tests__/maybe_highlight_test.tsx +++ b/frontend/settings/__tests__/maybe_highlight_test.tsx @@ -12,7 +12,7 @@ jest.mock("../../redux/store", () => ({ import React from "react"; import { mount, shallow } from "enzyme"; import { - Highlight, HighlightProps, maybeHighlight, maybeOpenPanel, highlight, + Highlight, HighlightProps, maybeOpenPanel, } from "../maybe_highlight"; import { Actions, DeviceSetting } from "../../constants"; import { toggleControlPanel, bulkToggleControlPanel } from "../toggle_section"; @@ -27,11 +27,13 @@ describe("", () => { }); it("fades highlight", () => { + location.search = "?highlight=motors"; + jest.useFakeTimers(); const p = fakeProps(); - const wrapper = mount(); - wrapper.setState({ className: "highlight" }); - wrapper.instance().componentDidMount(); - expect(wrapper.state().className).toEqual("unhighlight"); + const wrapper = mount(); + jest.runAllTimers(); + wrapper.update(); + expect(wrapper.find("div").first().hasClass("unhighlight")).toBeTruthy(); }); it("doesn't hide: no search term", () => { @@ -104,19 +106,10 @@ describe("", () => { it("shows anchor link icon on hover", () => { const wrapper = shallow(); - expect(wrapper.state().hovered).toEqual(false); expect(wrapper.find("i").last().hasClass("hovered")).toEqual(false); wrapper.simulate("mouseEnter"); - expect(wrapper.state().hovered).toEqual(true); - expect(wrapper.find("i").last().hasClass("hovered")).toEqual(true); - }); - - it("hides anchor link icon on unhover", () => { - const wrapper = shallow(); - wrapper.setState({ hovered: true }); expect(wrapper.find("i").last().hasClass("hovered")).toEqual(true); wrapper.simulate("mouseLeave"); - expect(wrapper.state().hovered).toEqual(false); expect(wrapper.find("i").last().hasClass("hovered")).toEqual(false); }); @@ -143,44 +136,7 @@ describe("", () => { }); }); -describe("maybeHighlight()", () => { - beforeEach(() => { - highlight.opened = false; - highlight.highlighted = false; - }); - - it("highlights only once", () => { - location.search = "?highlight=motors"; - expect(maybeHighlight(DeviceSetting.motors)).toEqual("highlight"); - expect(maybeHighlight(DeviceSetting.motors)).toEqual(""); - }); - - it("doesn't highlight: different setting", () => { - location.search = "?highlight=name"; - expect(maybeHighlight(DeviceSetting.motors)).toEqual(""); - }); - - it("doesn't highlight: no matches", () => { - location.search = "?highlight=na"; - expect(maybeHighlight(DeviceSetting.motors)).toEqual(""); - }); -}); - describe("maybeOpenPanel()", () => { - beforeEach(() => { - highlight.opened = false; - highlight.highlighted = false; - }); - - it("opens panel only once", () => { - location.search = "?highlight=motors"; - maybeOpenPanel()(jest.fn()); - expect(toggleControlPanel).toHaveBeenCalledWith("motors"); - jest.resetAllMocks(); - maybeOpenPanel()(jest.fn()); - expect(toggleControlPanel).not.toHaveBeenCalled(); - }); - it("doesn't open panel: no search term", () => { location.search = ""; maybeOpenPanel()(jest.fn()); diff --git a/frontend/settings/account/__tests__/change_password_test.tsx b/frontend/settings/account/__tests__/change_password_test.tsx index 08a52751ec..202fa02072 100644 --- a/frontend/settings/account/__tests__/change_password_test.tsx +++ b/frontend/settings/account/__tests__/change_password_test.tsx @@ -40,6 +40,7 @@ describe("", () => { el.instance().maybeClearForm(); el.update(); expect(el.instance().state.status).toBe(SpecialStatus.SAVED); + el.unmount(); }); it("sets a field", () => { diff --git a/frontend/settings/fbos_settings/os_update_button.tsx b/frontend/settings/fbos_settings/os_update_button.tsx index 3352078b99..afc492b1ec 100644 --- a/frontend/settings/fbos_settings/os_update_button.tsx +++ b/frontend/settings/fbos_settings/os_update_button.tsx @@ -9,7 +9,7 @@ import { isString } from "lodash"; import { Actions, Content, DeviceSetting } from "../../constants"; import { t } from "../../i18next_wrapper"; import { API } from "../../api"; -import { highlight, linkToSetting } from "../maybe_highlight"; +import { linkToSetting } from "../maybe_highlight"; import { isJobDone } from "../../devices/jobs"; import { NavigateFunction, useNavigate } from "react-router"; @@ -132,7 +132,6 @@ export const OsUpdateButton = (props: OsUpdateButtonProps) => { }; const onTooOld = (dispatch: Function, navigate: NavigateFunction) => () => { - highlight.highlighted = false; dispatch(bulkToggleControlPanel(false)); dispatch(toggleControlPanel("power_and_reset")); navigate(linkToSetting(DeviceSetting.hardReset)); diff --git a/frontend/settings/index.tsx b/frontend/settings/index.tsx index 4e9e3bfec2..3a87ceeda3 100644 --- a/frontend/settings/index.tsx +++ b/frontend/settings/index.tsx @@ -14,14 +14,14 @@ import { AxisSettings, Motors, EncodersOrStallDetection, LimitSwitches, ErrorHandling, PinGuard, ParameterManagement, PinReporting, ShowAdvancedToggle, } from "./hardware_settings"; -import { getHighlightName, maybeOpenPanel } from "./maybe_highlight"; +import { maybeOpenPanel } from "./maybe_highlight"; import { isBotOnlineFromState } from "../devices/must_be_online"; import { DesignerSettingsProps } from "./interfaces"; import { Designer } from "./farm_designer_settings"; import { SearchField } from "../ui/search_field"; import { mapStateToProps } from "./state_to_props"; import { Actions } from "../constants"; -import { ExtraSettings } from "../farm_designer/map/easter_eggs/bugs"; +import { BugsSettings } from "../farm_designer/map/easter_eggs/bugs"; import { OtherSettings } from "./other_settings"; import { CustomSettings } from "./custom_settings"; import { AccountSettings } from "./account/account_settings"; @@ -34,174 +34,172 @@ import { ReSeedAccount } from "../messages/cards"; import { InterpolationSettings, } from "../farm_designer/map/layers/points/interpolation_map"; -import { getUrlQuery, urlFriendly } from "../util"; +import { getUrlQuery } from "../util"; import { Popover } from "../ui"; import { Position } from "@blueprintjs/core"; -import { NavigationContext } from "../routes_helpers"; import { ThreeDSettings } from "./three_d_settings"; +import { useLocation, useNavigate } from "react-router"; -export class RawDesignerSettings - extends React.Component { +export const RawDesignerSettings = (props: DesignerSettingsProps) => { + const navigate = useNavigate(); + const location = useLocation(); - componentDidMount = () => this.props.dispatch(maybeOpenPanel()); + const { + getConfigValue, dispatch, firmwareConfig, + sourceFwConfig, sourceFbosConfig, resources, settingsPanelState, + } = props; - componentWillUnmount = () => { - this.props.dispatch({ - type: Actions.SET_SETTINGS_SEARCH_TERM, - payload: "" - }); - }; + React.useEffect(() => { + dispatch(maybeOpenPanel()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location]); - static contextType = NavigationContext; - context!: React.ContextType; - navigate = this.context; - - render() { - const { getConfigValue, dispatch, firmwareConfig, - sourceFwConfig, sourceFbosConfig, resources, settingsPanelState, - } = this.props; - const showAdvanced = !!getConfigValue(BooleanSetting.show_advanced_settings); - const commonProps = { dispatch, settingsPanelState, showAdvanced }; - const { value } = this.props.sourceFbosConfig("firmware_hardware"); - const firmwareHardware = validFirmwareHardware(value); - const botOnline = isBotOnlineFromState(this.props.bot); - const { busy } = this.props.bot.hardware.informational_settings; - const urlSearchTerm = (getUrlQuery("search") || "").replace(/_/g, " "); - urlSearchTerm && this.props.searchTerm != urlSearchTerm && dispatch({ - type: Actions.SET_SETTINGS_SEARCH_TERM, - payload: urlSearchTerm, - }); - return - - { - getUrlQuery("search") && this.navigate(location.pathname); - dispatch(bulkToggleControlPanel(searchTerm != "")); - dispatch({ - type: Actions.SET_SETTINGS_SEARCH_TERM, - payload: searchTerm, - }); - }} /> -
- } - content={} /> - -
-
- - {getUrlQuery("only") && !this.props.searchTerm && -
-

{t("showing single setting")}

- -
} - - - {botOnline && showAdvanced && } - - - - - - - + + { + getUrlQuery("search") && navigate(location.pathname); + dispatch(bulkToggleControlPanel(searchTerm != "")); + dispatch({ + type: Actions.SET_SETTINGS_SEARCH_TERM, + payload: searchTerm, + }); + }} /> +
+ } + content={} /> + +
+
+ + {getUrlQuery("only") && !props.searchTerm && +
+

{t("showing single setting")}

+ +
} + + + {botOnline && showAdvanced && } + + + + + + + + {showByTerm("pin reporting", props.searchTerm) && + - {showByTerm("pin reporting", this.props.searchTerm) && - } - - - - - - - {showByTerm("setup", this.props.searchTerm) && - } - {showByTerm("re-seed", this.props.searchTerm) && - } - {showByTerm("interpolation", this.props.searchTerm) && - } - {showByTerm("developer", this.props.searchTerm) && - } - {ExtraSettings(this.props.searchTerm)} -
-
; - } -} + sourceFwConfig={sourceFwConfig} />} + + + + + + + {showByTerm("setup", props.searchTerm) && + } + {showByTerm("re-seed", props.searchTerm) && + } + {showByTerm("interpolation", props.searchTerm) && + } + {showByTerm("developer", props.searchTerm) && + } + {showByEveryTerm("surprise", props.searchTerm) && + } + + ; +}; + +const searchTermMatch = (term: string, searchTerm: string) => + searchTerm.toLowerCase() == term.toLowerCase(); +const queryMatch = (term: string) => getUrlQuery("only") == term; const showByTerm = (term: string, searchTerm: string) => - getUrlQuery("only") == term || searchTerm.toLowerCase() == term.toLowerCase(); + queryMatch(term) || searchTermMatch(term, searchTerm); -export const showByEveryTerm = (term: string, searchTerm: string) => - searchTerm == term && getHighlightName() == urlFriendly(term).toLowerCase(); +const showByEveryTerm = (term: string, searchTerm: string) => + queryMatch(term) && searchTermMatch(term, searchTerm); export const DesignerSettings = connect(mapStateToProps)(RawDesignerSettings); // eslint-disable-next-line import/no-default-export diff --git a/frontend/settings/maybe_highlight.tsx b/frontend/settings/maybe_highlight.tsx index 1278b8b243..ac514f12ac 100644 --- a/frontend/settings/maybe_highlight.tsx +++ b/frontend/settings/maybe_highlight.tsx @@ -8,6 +8,7 @@ import { trim, some } from "lodash"; import { Path } from "../internal_urls"; import { PhotosPanelState } from "../photos/interfaces"; import { NavigationContext } from "../routes_helpers"; +import { useLocation } from "react-router"; const FARMBOT_PANEL = [ DeviceSetting.farmbotSettings, @@ -396,12 +397,6 @@ const compareValues = (settingName: DeviceSetting) => .concat(stripUnits(settingName)) .map(s => urlFriendly(s).toLowerCase()); -/** Retrieve a highlight search term. */ -export const getHighlightName = () => getUrlQuery("highlight"); - -/** Only open panel and highlight once per app load. Exported for tests. */ -export const highlight = { opened: false, highlighted: false }; - /** Open a panel if a setting in that panel is highlighted. */ export const maybeOpenPanel = (panelKey: "settings" | "photos" = "settings") => (dispatch: Function) => { @@ -409,9 +404,9 @@ export const maybeOpenPanel = (panelKey: "settings" | "photos" = "settings") => dispatch(bulkToggleControlPanel(true)); return; } - if (highlight.opened) { return; } - const urlFriendlySettingName = urlFriendly(getHighlightName() || "") - .toLowerCase(); + const highlightName = + new URLSearchParams(window.location.search).get("highlight"); + const urlFriendlySettingName = urlFriendly(highlightName || "").toLowerCase(); if (!urlFriendlySettingName) { return; } if (panelKey == "settings") { const panel = URL_FRIENDLY_LOOKUP[urlFriendlySettingName]; @@ -423,19 +418,8 @@ export const maybeOpenPanel = (panelKey: "settings" | "photos" = "settings") => URL_FRIENDLY_LOOKUP_PHOTOS[urlFriendlySettingName].map(panel => dispatch({ type: Actions.TOGGLE_PHOTOS_PANEL_OPTION, payload: panel })); } - highlight.opened = true; }; -/** Highlight a setting if provided as a search term. */ -export const maybeHighlight = (settingName: DeviceSetting) => { - const item = getHighlightName(); - if (highlight.highlighted || !item) { return ""; } - const isCurrentSetting = compareValues(settingName).includes(item); - if (!isCurrentSetting) { return ""; } - highlight.highlighted = true; - return "highlight"; -}; - export interface HighlightProps { settingName: DeviceSetting; children: React.ReactNode; @@ -445,96 +429,98 @@ export interface HighlightProps { pathPrefix?(path?: string): string; } -interface HighlightState { - className: string; - hovered: boolean; -} - /** Wrap highlight-able settings. */ -export class Highlight extends React.Component { - state: HighlightState = { - className: maybeHighlight(this.props.settingName), - hovered: false, - }; +export const Highlight = (props: HighlightProps) => { + const { settingName } = props; + + const [hovered, setHovered] = React.useState(false); + const [highlightClass, setHighlightClass] = React.useState(""); + const [highlightTimestamp, setHighlightTimestamp] = React.useState(0); + + const navigate = React.useContext(NavigationContext); + const location = useLocation(); - componentDidMount = () => { - if (this.state.className == "highlight") { - /** Slowly fades highlight. */ - this.setState({ className: "unhighlight" }); + const highlightName = new URLSearchParams(location.search).get("highlight"); + const highlightMatch = highlightName && + compareValues(settingName).includes(highlightName.toLowerCase()); + + React.useEffect(() => { + if (highlightMatch) { + setHighlightTimestamp(Date.now()); } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location]); - get searchTerm() { - const { app } = store.getState(); - return app.settingsSearchTerm; - } + React.useEffect(() => { + if (!highlightMatch) { + setHighlightClass(""); + return; + } + setHighlightClass("highlight"); + setTimeout(() => setHighlightClass("unhighlight"), 200); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [highlightTimestamp]); - toggleHover = (hovered: boolean) => () => this.setState({ hovered }); + const searchTerm = store.getState().app.settingsSearchTerm; - get isSectionHeader() { return this.props.className?.includes("section"); } + const isSectionHeader = props.className?.includes("section"); - inContent = (term: string, urlCompare = false) => { - const content = CONTENT_LOOKUP[this.props.settingName] || []; + const inContent = (term: string, urlCompare = false) => { + const content = CONTENT_LOOKUP[settingName] || []; return some(content.map(s => { const compareTerm = urlCompare ? compareValues(s)[0] : s; return compareTerm.toLowerCase().includes(term.toLowerCase()); })); }; - get searchMatch() { - return this.searchTerm && + const searchMatch = () => { + return searchTerm && // if searching, look for setting name match - (some(ALTERNATE_NAMES[this.props.settingName].map(s => s.toLowerCase() - .includes(this.searchTerm.toLowerCase()))) + (some(ALTERNATE_NAMES[settingName].map(s => s.toLowerCase() + .includes(searchTerm.toLowerCase()))) // if match not found, look for section content match - || (this.isSectionHeader && this.inContent(this.searchTerm))); - } + || (isSectionHeader && inContent(searchTerm))); + }; - get hidden() { + const hidden = () => { const isolateName = getUrlQuery("only"); if (isolateName) { - const inSection = this.isSectionHeader && this.inContent(isolateName, true); + const inSection = isSectionHeader && inContent(isolateName, true); const settingMatch = - compareValues(this.props.settingName).includes(isolateName); + compareValues(settingName).includes(isolateName); return !(inSection || settingMatch); } - const highlightName = getHighlightName(); - if (!highlightName) { return !!this.props.hidden; } - const highlightMatch = - compareValues(this.props.settingName).includes(highlightName); - const highlightInSection = this.isSectionHeader - && this.inContent(highlightName, true) || highlightMatch; + if (!highlightName) { return !!props.hidden; } + const highlightInSection = isSectionHeader + && inContent(highlightName, true) || highlightMatch; const notHighlighted = - SETTING_PANEL_LOOKUP[this.props.settingName] == "other_settings" && + SETTING_PANEL_LOOKUP[settingName] == "other_settings" && !highlightMatch; - return this.props.hidden ? !highlightInSection : notHighlighted; - } - - static contextType = NavigationContext; - context!: React.ContextType; - navigate = this.context; + return props.hidden ? !highlightInSection : notHighlighted; + }; - render() { - const hoverClass = this.state.hovered ? "hovered" : ""; - return ; - } -} + return
setHovered(true)} + onMouseLeave={() => setHovered(false)} + hidden={searchTerm ? !searchMatch() : hidden()}> + {settingName && + { + navigate(linkToSetting(settingName, props.pathPrefix)); + }} />} + {props.children} +
; +}; export const linkToSetting = (settingName: DeviceSetting, pathPrefix = Path.settings) => diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index 65a82131cf..3e5c032155 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -16,6 +16,7 @@ import { BooleanSetting } from "../session_keys"; import { LayerToggle } from "../farm_designer/map/legend/layer_toggle"; import { setWebAppConfigValue } from "../config_storage/actions"; import { DesignerState } from "../farm_designer/interfaces"; +import { setPanelOpen } from "../farm_designer/panel_header"; export interface ThreeDGardenProps { config: Config; @@ -61,7 +62,10 @@ export const ThreeDGardenToggle = (props: ThreeDGardenToggleProps) => { {threeDGarden && } {threeDGarden && From f0c2290965b5c9d1aa3735d01a7641d6b0083ffa Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 2 Apr 2025 16:17:51 -0700 Subject: [PATCH 07/15] open panel upon doc link click --- .../move/__tests__/jog_buttons_test.tsx | 1 + frontend/controls/move/bot_position_rows.tsx | 6 ++- frontend/controls/move/jog_buttons.tsx | 9 +++- .../connectivity/__tests__/diagnosis_test.tsx | 1 + .../devices/connectivity/connectivity.tsx | 8 +++- frontend/devices/connectivity/diagnosis.tsx | 19 +++++++-- frontend/devices/connectivity/qos_panel.tsx | 6 ++- frontend/farmware/farmware_forms.tsx | 3 +- frontend/help/documentation.tsx | 13 ++++-- .../nav/__tests__/additional_menu_test.tsx | 15 +++++-- frontend/nav/__tests__/nav_links_test.tsx | 4 +- frontend/nav/__tests__/ticker_list_test.tsx | 1 - frontend/nav/additional_menu.tsx | 18 ++++++-- frontend/nav/index.tsx | 14 ++++--- frontend/nav/interfaces.ts | 9 ++-- frontend/nav/mobile_menu.tsx | 2 +- frontend/nav/nav_links.tsx | 8 ++-- frontend/photos/camera_calibration/index.tsx | 8 +++- frontend/photos/photos.tsx | 6 ++- .../sequences/step_tiles/tile_assertion.tsx | 6 ++- frontend/sequences/step_tiles/tile_lua.tsx | 6 ++- frontend/settings/custom_settings.tsx | 6 ++- .../__tests__/factory_reset_row_test.tsx | 1 + .../fbos_settings/factory_reset_row.tsx | 6 ++- frontend/settings/fbos_settings/interfaces.ts | 1 + .../fbos_settings/os_update_button.tsx | 2 + .../fbos_settings/power_and_reset.tsx | 4 +- frontend/ui/__tests__/doc_link_test.ts | 42 +++++++++++-------- frontend/ui/__tests__/tooltip_test.tsx | 4 +- frontend/ui/doc_link.ts | 32 ++++++++++---- frontend/ui/tooltip.tsx | 18 ++++---- frontend/wizard/checks.tsx | 3 +- 32 files changed, 198 insertions(+), 84 deletions(-) diff --git a/frontend/controls/move/__tests__/jog_buttons_test.tsx b/frontend/controls/move/__tests__/jog_buttons_test.tsx index 628993088c..231b28f873 100644 --- a/frontend/controls/move/__tests__/jog_buttons_test.tsx +++ b/frontend/controls/move/__tests__/jog_buttons_test.tsx @@ -95,6 +95,7 @@ describe("", () => { const fakeProps = (): PowerAndResetMenuProps => ({ botOnline: true, showAdvanced: true, + dispatch: jest.fn(), }); it("restarts firmware", () => { diff --git a/frontend/controls/move/bot_position_rows.tsx b/frontend/controls/move/bot_position_rows.tsx index d3a4d07e24..4e4f9e8387 100644 --- a/frontend/controls/move/bot_position_rows.tsx +++ b/frontend/controls/move/bot_position_rows.tsx @@ -27,6 +27,7 @@ import { import { NumberConfigKey } from "farmbot/dist/resources/configs/firmware"; import { isUndefined } from "lodash"; import { calculateScale } from "../../settings/hardware_settings"; +import { setPanelOpen } from "../../farm_designer/panel_header"; export const BotPositionRows = (props: BotPositionRowsProps) => { const { locationData, getConfigValue, arduinoBusy, locked } = props; @@ -141,7 +142,10 @@ export const AxisActions = (props: AxisActionsProps) => { onClick={setAxisLength({ axis, dispatch, botPosition, sourceFwConfig })}> {t("SET LENGTH")} - { navigate(Path.settings("axes")); }}> + { + dispatch(setPanelOpen(true)); + navigate(Path.settings("axes")); + }}> {t("Settings")} diff --git a/frontend/controls/move/jog_buttons.tsx b/frontend/controls/move/jog_buttons.tsx index cc16ff2726..3e9f3cb2d8 100644 --- a/frontend/controls/move/jog_buttons.tsx +++ b/frontend/controls/move/jog_buttons.tsx @@ -97,7 +97,9 @@ export class JogButtons target={
; }; diff --git a/frontend/devices/connectivity/__tests__/diagnosis_test.tsx b/frontend/devices/connectivity/__tests__/diagnosis_test.tsx index 5c4bbfb889..6e713c4e8b 100644 --- a/frontend/devices/connectivity/__tests__/diagnosis_test.tsx +++ b/frontend/devices/connectivity/__tests__/diagnosis_test.tsx @@ -17,6 +17,7 @@ describe("", () => { botAPI: true, botFirmware: true, }, + dispatch: jest.fn(), }); it("renders help text", () => { diff --git a/frontend/devices/connectivity/connectivity.tsx b/frontend/devices/connectivity/connectivity.tsx index 592bbf31ff..32ae7f5d59 100644 --- a/frontend/devices/connectivity/connectivity.tsx +++ b/frontend/devices/connectivity/connectivity.tsx @@ -114,7 +114,7 @@ export class Connectivity : undefined} hover={this.hover} hoveredConnection={this.state.hoveredConnection} />)} - + {this.props.flags.userAPI && this.props.flags.userMQTT && this.props.flags.botAPI && this.props.flags.botMQTT && this.props.apiFirmwareValue @@ -162,7 +162,11 @@ export class Connectivity - + {t("Learn more about ports")} diff --git a/frontend/devices/connectivity/diagnosis.tsx b/frontend/devices/connectivity/diagnosis.tsx index ecc9343aa2..2fba974667 100644 --- a/frontend/devices/connectivity/diagnosis.tsx +++ b/frontend/devices/connectivity/diagnosis.tsx @@ -8,6 +8,7 @@ import { SyncStatus } from "farmbot"; import { syncText } from "../../nav/sync_text"; import { useNavigate } from "react-router"; import { linkToSetting } from "../../settings/maybe_highlight"; +import { setPanelOpen } from "../../farm_designer/panel_header"; export type ConnectionName = | "userAPI" @@ -20,6 +21,7 @@ export type ConnectionStatusFlags = Record; export interface DiagnosisProps { statusFlags: ConnectionStatusFlags; hideGraphic?: boolean; + dispatch: Function; } export interface DiagnosisSaucerProps extends ConnectionStatusFlags { className?: string; @@ -61,7 +63,10 @@ export function Diagnosis(props: DiagnosisProps) {

{t("Always")}  { navigate(linkToSetting(DeviceSetting.farmbotOS)); }}> + onClick={() => { + props.dispatch(setPanelOpen(true)); + navigate(linkToSetting(DeviceSetting.farmbotOS)); + }}> {t("upgrade FarmBot OS")}  {t("before troubleshooting.")} @@ -69,11 +74,19 @@ export function Diagnosis(props: DiagnosisProps) {

{diagnosisMessage(getDiagnosisCode(props.statusFlags))}

- + {t("Click here to learn more about connectivity codes.")} - + {t("Click here for document to show to your IT department.")} diff --git a/frontend/devices/connectivity/qos_panel.tsx b/frontend/devices/connectivity/qos_panel.tsx index a2012a2f51..16d7ddfc58 100644 --- a/frontend/devices/connectivity/qos_panel.tsx +++ b/frontend/devices/connectivity/qos_panel.tsx @@ -88,7 +88,11 @@ export class QosPanel extends React.Component { + docLinkClick({ + slug: "connecting-farmbot-to-the-internet", + navigate: this.navigate, + dispatch: this.props.dispatch, + })}> {t("Learn more about connecting")} diff --git a/frontend/farmware/farmware_forms.tsx b/frontend/farmware/farmware_forms.tsx index 7634257e90..459174bb7b 100644 --- a/frontend/farmware/farmware_forms.tsx +++ b/frontend/farmware/farmware_forms.tsx @@ -188,7 +188,8 @@ export class FarmwareForm const collapsedConfigs = farmware.config.filter(collapsed); return
- {this.props.docPage && } + {this.props.docPage && + }