From cc934fe333c3e21c8848b5e19acfa4c2847134db Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 16 Sep 2025 17:20:23 -0700 Subject: [PATCH] IO-3373 Dashboard Errors on Large Datasets Signed-off-by: Allan Carr --- .../dashboard-grid/createDashboardQuery.js | 10 +- .../dashboard-grid.component.jsx | 107 ++++++++++++------ 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/client/src/components/dashboard-grid/createDashboardQuery.js b/client/src/components/dashboard-grid/createDashboardQuery.js index 1b0ce0a3f..5f0e7b322 100644 --- a/client/src/components/dashboard-grid/createDashboardQuery.js +++ b/client/src/components/dashboard-grid/createDashboardQuery.js @@ -2,11 +2,13 @@ import { gql } from "@apollo/client"; import dayjs from "../../utils/day.js"; import componentList from "./componentList.js"; -const createDashboardQuery = (state) => { +const createDashboardQuery = (items) => { const componentBasedAdditions = - state && - Array.isArray(state.layout) && - state.layout.map((item) => componentList[item.i].gqlFragment || "").join(""); + Array.isArray(items) && + items + .map((item) => (componentList[item.i] && componentList[item.i].gqlFragment) || "") + .filter(Boolean) + .join(""); return gql` query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""} monthly_sales: jobs(where: {_and: [ diff --git a/client/src/components/dashboard-grid/dashboard-grid.component.jsx b/client/src/components/dashboard-grid/dashboard-grid.component.jsx index 546481e2e..929681bbd 100644 --- a/client/src/components/dashboard-grid/dashboard-grid.component.jsx +++ b/client/src/components/dashboard-grid/dashboard-grid.component.jsx @@ -1,5 +1,5 @@ import Icon, { SyncOutlined } from "@ant-design/icons"; -import { cloneDeep, isEmpty } from "lodash"; +import { cloneDeep } from "lodash"; import { useMutation, useQuery } from "@apollo/client"; import { Button, Dropdown, Space } from "antd"; import { PageHeader } from "@ant-design/pro-layout"; @@ -34,14 +34,25 @@ const mapDispatchToProps = () => ({ export function DashboardGridComponent({ currentUser, bodyshop }) { const { t } = useTranslation(); - const [state, setState] = useState({ - ...(bodyshop.associations[0].user.dashboardlayout - ? bodyshop.associations[0].user.dashboardlayout - : { items: [], layout: {}, layouts: [] }) + const [state, setState] = useState(() => { + const persisted = bodyshop.associations[0].user.dashboardlayout; + // Normalize persisted structure to avoid malformed shapes that can cause recursive layout recalculations + if (persisted) { + return { + items: Array.isArray(persisted.items) ? persisted.items : [], + layout: Array.isArray(persisted.layout) ? persisted.layout : [], + layouts: typeof persisted.layouts === "object" && !Array.isArray(persisted.layouts) ? persisted.layouts : {}, + cols: persisted.cols + }; + } + return { items: [], layout: [], layouts: {}, cols: 12 }; }); const notification = useNotification(); - const { loading, error, data, refetch } = useQuery(createDashboardQuery(state), { + // Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches + const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]); + + const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); @@ -49,21 +60,32 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); const handleLayoutChange = async (layout, layouts) => { - logImEXEvent("dashboard_change_layout"); + try { + logImEXEvent("dashboard_change_layout"); - setState({ ...state, layout, layouts }); + setState((prev) => ({ ...prev, layout, layouts })); - const result = await updateLayout({ - variables: { - email: currentUser.email, - layout: { ...state, layout, layouts } + const result = await updateLayout({ + variables: { + email: currentUser.email, + layout: { ...state, layout, layouts } + } + }); + + if (result?.errors && result.errors.length) { + const errorMessages = result.errors.map((e) => e?.message || String(e)); + notification.error({ + message: t("dashboard.errors.updatinglayout", { + message: errorMessages.join("; ") + }) + }); } - }); - - if (!isEmpty(result?.errors)) { + } catch (err) { + // Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled + console.error("Dashboard layout update failed", err); notification.error({ message: t("dashboard.errors.updatinglayout", { - message: JSON.stringify(result.errors) + message: err?.message || String(err) }) }); } @@ -80,19 +102,26 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { }; const handleAddComponent = (e) => { - logImEXEvent("dashboard_add_component", { name: e }); - setState({ - ...state, - items: [ - ...state.items, + // Avoid passing the full AntD menu click event (contains circular refs) to analytics + logImEXEvent("dashboard_add_component", { key: e.key }); + const compSpec = componentList[e.key] || {}; + const minW = compSpec.minW || 1; + const minH = compSpec.minH || 1; + const baseW = compSpec.w || 2; + const baseH = compSpec.h || 2; + setState((prev) => { + const nextItems = [ + ...prev.items, { i: e.key, - x: (state.items.length * 2) % (state.cols || 12), - y: 99, // puts it at the bottom - w: componentList[e.key].w || 2, - h: componentList[e.key].h || 2 + // Position near bottom: use a large y so RGL places it last without triggering cascading relayout loops + x: (prev.items.length * 2) % (prev.cols || 12), + y: 1000, + w: Math.max(baseW, minW), + h: Math.max(baseH, minH) } - ] + ]; + return { ...prev, items: nextItems }; }); }; @@ -130,25 +159,33 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { className="layout" breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} - width="100%" layouts={state.layouts} onLayoutChange={handleLayoutChange} > {state.items.map((item) => { - const TheComponent = componentList[item.i].component; + const spec = componentList[item.i] || {}; + const TheComponent = spec.component; + const minW = spec.minW || 1; + const minH = spec.minH || 1; + // Ensure current width/height respect minimums to avoid react-grid-layout prop warnings + const safeItem = { + ...item, + w: Math.max(item.w || spec.w || minW, minW), + h: Math.max(item.h || spec.h || minH, minH) + }; return (
handleRemoveComponent(item.i)} + onClick={() => handleRemoveComponent(safeItem.i)} /> - + {TheComponent && }
);