|
|
|
|
@@ -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 <LoadingSkeleton message={t("general.labels.loading")} />;
|
|
|
|
|
if (error || dashboardError) return <AlertComponent message={(error || dashboardError).message} type="error" />;
|
|
|
|
|
|
|
|
|
|
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 <AlertComponent message={error.message} type="error" />;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<PageHeader
|
|
|
|
|
extra={
|
|
|
|
|
<Space>
|
|
|
|
|
<Button onClick={() => refetch()}>
|
|
|
|
|
<Button onClick={refetch}>
|
|
|
|
|
<SyncOutlined />
|
|
|
|
|
</Button>
|
|
|
|
|
<Dropdown menu={menu} trigger={["click"]}>
|
|
|
|
|
@@ -157,22 +214,19 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|
|
|
|
|
|
|
|
|
<ResponsiveReactGridLayout
|
|
|
|
|
className="layout"
|
|
|
|
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
|
|
|
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
|
|
|
|
breakpoints={GRID_BREAKPOINTS}
|
|
|
|
|
cols={GRID_COLS}
|
|
|
|
|
layouts={state.layouts}
|
|
|
|
|
onLayoutChange={handleLayoutChange}
|
|
|
|
|
>
|
|
|
|
|
{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 (
|
|
|
|
|
<div
|
|
|
|
|
key={safeItem.i}
|
|
|
|
|
|