diff --git a/client/src/components/dashboard-grid/dashboard-grid.component.jsx b/client/src/components/dashboard-grid/dashboard-grid.component.jsx index 929681bbd..8a214051e 100644 --- a/client/src/components/dashboard-grid/dashboard-grid.component.jsx +++ b/client/src/components/dashboard-grid/dashboard-grid.component.jsx @@ -1,17 +1,17 @@ import Icon, { SyncOutlined } from "@ant-design/icons"; -import { cloneDeep } from "lodash"; -import { useMutation, useQuery } from "@apollo/client"; +import { useMutation, useQuery, useApolloClient } from "@apollo/client"; import { Button, Dropdown, Space } from "antd"; import { PageHeader } from "@ant-design/pro-layout"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useEffect } from "react"; import { Responsive, WidthProvider } from "react-grid-layout"; import { useTranslation } from "react-i18next"; import { MdClose } from "react-icons/md"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { logImEXEvent } from "../../firebase/firebase.utils"; -import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries"; -import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +import { UPDATE_DASHBOARD_LAYOUT, QUERY_USER_DASHBOARD_LAYOUT } from "../../graphql/user.queries"; +import { QUERY_DASHBOARD_BODYSHOP } from "../../graphql/bodyshop.queries"; +import { selectCurrentUser } from "../../redux/user/user.selectors"; import AlertComponent from "../alert/alert.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import { GenerateDashboardData } from "./dashboard-grid.utils"; @@ -24,128 +24,185 @@ import "./dashboard-grid.styles.scss"; const ResponsiveReactGridLayout = WidthProvider(Responsive); const mapStateToProps = createStructuredSelector({ - currentUser: selectCurrentUser, - bodyshop: selectBodyshop + currentUser: selectCurrentUser }); const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); -export function DashboardGridComponent({ currentUser, bodyshop }) { +export function DashboardGridComponent({ currentUser }) { const { t } = useTranslation(); - 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 client = useApolloClient(); const notification = useNotification(); - // 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]); + // Constants for layout defaults + const DEFAULT_COLS = 12; + const DEFAULT_Y_POSITION = 1000; + const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; + const GRID_COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }; - const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, { + // Fetch dashboard layout data + const { data: layoutData } = useQuery(QUERY_USER_DASHBOARD_LAYOUT, { + variables: { email: currentUser.email }, + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + skip: !currentUser?.email + }); + + // Fetch minimal bodyshop data for components + const { + loading, + error, + data: bodyshopData + } = useQuery(QUERY_DASHBOARD_BODYSHOP, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT); - const handleLayoutChange = async (layout, layouts) => { + // Memoize layout state initialization + const initialState = useMemo(() => { + const persisted = layoutData?.users?.[0]?.dashboardlayout; + if (persisted) { + const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = persisted; + return { + items: Array.isArray(items) ? items : [], + layout: Array.isArray(layout) ? layout : [], + layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {}, + cols + }; + } + return { items: [], layout: [], layouts: {}, cols: DEFAULT_COLS }; + }, [layoutData]); + + const [state, setState] = useState(initialState); + + // Update state when layout data changes + useEffect(() => { + if (layoutData?.users?.[0]?.dashboardlayout) { + const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = layoutData.users[0].dashboardlayout; + setState({ + items: Array.isArray(items) ? items : [], + layout: Array.isArray(layout) ? layout : [], + layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {}, + cols + }); + } + }, [layoutData]); + + // Get bodyshop data for components + const bodyshop = bodyshopData?.dashboard_bodyshops?.[0]; + + // DRY helper function to update layout in database and cache + const updateLayoutAndCache = async (updatedLayout, errorContext = "updating layout") => { try { - logImEXEvent("dashboard_change_layout"); - - setState((prev) => ({ ...prev, layout, layouts })); - - const result = await updateLayout({ - variables: { - email: currentUser.email, - layout: { ...state, layout, layouts } - } + const { data: result } = await updateLayout({ + variables: { email: currentUser.email, layout: updatedLayout } }); - if (result?.errors && result.errors.length) { - const errorMessages = result.errors.map((e) => e?.message || String(e)); + const { errors = [] } = result?.update_users?.returning?.[0] || {}; + + if (errors.length) { + const errorMessages = errors.map(({ message }) => message || String(error)); notification.error({ message: t("dashboard.errors.updatinglayout", { message: errorMessages.join("; ") }) }); + return false; } + + // Note: Removed Apollo cache update to prevent triggering unwanted Redux actions + // Instead, evict the dashboard bodyshop query from cache to ensure fresh data on next fetch + client.cache.evict({ fieldName: "dashboard_bodyshops" }); + client.cache.gc(); + + return true; } catch (err) { - // Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled - console.error("Dashboard layout update failed", err); + console.error(`Dashboard ${errorContext} failed`, err); notification.error({ message: t("dashboard.errors.updatinglayout", { message: err?.message || String(err) }) }); + return false; } }; - const handleRemoveComponent = (key) => { + // 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: dashboardLoading, + error: dashboardError, + data: dashboardQueryData, + refetch + } = useQuery(dashboardQueryDoc, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only" + }); + + const dashboardData = useMemo(() => GenerateDashboardData(dashboardQueryData), [dashboardQueryData]); + + // Memoize existing layout keys to prevent unnecessary recalculations + const existingLayoutKeys = useMemo(() => state.items.map(({ i }) => i), [state.items]); + + // Memoize menu items to prevent unnecessary recalculations + const menuItems = useMemo( + () => + Object.entries(componentList).map(([key, { label }]) => ({ + key, + label, + value: key, + disabled: existingLayoutKeys.includes(key) + })), + [existingLayoutKeys] + ); + + if (loading || dashboardLoading) return ; + if (error || dashboardError) return ; + + const handleLayoutChange = async (layout, layouts) => { + logImEXEvent("dashboard_change_layout"); + setState((prev) => ({ ...prev, layout, layouts })); + await updateLayoutAndCache({ ...state, layout, layouts }, "layout change"); + }; + + const handleRemoveComponent = async (key) => { logImEXEvent("dashboard_remove_component", { name: key }); - const idxToRemove = state.items.findIndex((i) => i.i === key); - - const items = cloneDeep(state.items); - - items.splice(idxToRemove, 1); - setState({ ...state, items }); + const updatedState = { ...state, items: state.items.filter((item) => item.i !== key) }; + setState(updatedState); + await updateLayoutAndCache(updatedState, "component removal"); }; - const handleAddComponent = (e) => { - // 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, - // 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 }; - }); + const handleAddComponent = async ({ key }) => { + logImEXEvent("dashboard_add_component", { key }); + const { minW = 1, minH = 1, w: baseW = 2, h: baseH = 2 } = componentList[key] || {}; + const nextItems = [ + ...state.items, + { + i: key, + x: (state.items.length * 2) % (state.cols || DEFAULT_COLS), + y: DEFAULT_Y_POSITION, + w: Math.max(baseW, minW), + h: Math.max(baseH, minH) + } + ]; + const updatedState = { ...state, items: nextItems }; + setState(updatedState); + await updateLayoutAndCache(updatedState, "component addition"); }; - const dashboardData = useMemo(() => GenerateDashboardData(data), [data]); - - const existingLayoutKeys = state.items.map((i) => i.i); - - const menuItems = Object.keys(componentList).map((key) => ({ - key: key, - label: componentList[key].label, - value: key, - disabled: existingLayoutKeys.includes(key) - })); - const menu = { items: menuItems, onClick: handleAddComponent }; - if (error) return ; - return (
- @@ -157,22 +214,19 @@ export function DashboardGridComponent({ currentUser, bodyshop }) { {state.items.map((item) => { - 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 { component: TheComponent, minW = 1, minH = 1, w: specW, h: specH } = componentList[item.i] || {}; const safeItem = { ...item, - w: Math.max(item.w || spec.w || minW, minW), - h: Math.max(item.h || spec.h || minH, minH) + w: Math.max(item.w || specW || minW, minW), + h: Math.max(item.h || specH || minH, minH) }; + return (