When a new row is added formula is not updating automatically. I’m using Zustand store to manage the data and styles.
Page:
import { Container } from "@/components/common/container";
import { useCallback, useEffect, useRef, useState } from "react";
import { useUnsavedChangesProtection } from "@/hooks/use-unsaved-changes-protection";
import { BOMItem, RowStylesMap } from "@/types";
import { HotTableRef } from "@handsontable/react-wrapper";
import DataTable from "@/components/bom/tables/data-table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import SearchColumns from "@/components/bom/search-columns";
import { Button } from "@/components/ui/button";
import { FileOutput, SaveIcon, Table2Icon } from "lucide-react";
import { useStreamData } from "../../hooks/use-stream-data";
import {
convertToCsv,
exportCSV,
getNewRow,
transformDataToObjectsAndRemoveEmpty,
} from "@/components/bom/functions";
import { colHeaders, ColumnConfig } from "@/components/bom/config/columns";
import { axiosCall } from "@/lib/axios";
import { useQuery } from "@tanstack/react-query";
import SaveTemplateModal from "@/components/bom/save-template-modal";
import ShowHideColumns from "@/components/bom/tables/show-hide-columns";
import { useBomStore } from "@/stores/use-bom-store";
import { toast } from "sonner";
import ContentLoadingSpinner from "@/components/common/content-loading-spinner";
import { useClearTableFilters } from "@/components/bom/hooks/use-clear-table-filters";
import { debounce } from "@/lib/helpers";
import { useBomColumnsStore } from "@/stores/use-bom-columns-store";
import TableFilters from "@/components/bom/tables/table-filters";
export default function BOMManagementPage() {
const tableRef = useRef<HotTableRef>(null);
const { bom, styles, setBom, setStyles, addBomItem } = useBomStore();
const { hiddenColumns, initHiddenColumns } = useBomColumnsStore();
const [isSaving, setIsSaving] = useState(false);
const [hasChanged, setHasChanged] = useState(false);
const clearFilters = useClearTableFilters({ hot: tableRef.current?.hotInstance });
const [showSaveModal, setShowSaveModal] = useState(false);
const [filterValues, setFilterValues] = useState({
col: 0,
value: "",
});
useEffect(() => {
const cols = localStorage.getItem("bom-columns-store");
if (cols) {
const parsed = JSON.parse(cols);
if (parsed.state && Array.isArray(parsed.state.hiddenColumns)) {
initHiddenColumns(parsed.state.hiddenColumns);
}
}
}, [initHiddenColumns]);
/**
===============================================================
* Fetch data from the stream and styles from the API
* Use useStreamData hook to get data from the stream
* Use useQuery to fetch styles from the API
===============================================================
*/
const { data, loading, error } = useStreamData(`/items`);
const { data: stylesData, refetch } = useQuery<RowStylesMap, Error>({
queryKey: ["styles"],
queryFn: async () => {
const response = await axiosCall({ method: "GET", urlPath: `/items/styles` });
return response.data;
},
refetchOnWindowFocus: false,
});
useEffect(() => {
if (stylesData) {
setStyles(stylesData.data);
}
}, [setStyles, stylesData]);
/**
===============================================================
* @function applyFilters
* Apply filters to the table
* This function is called when the user clicks the apply button
* @function resetTableFilter
* resets the filter values to default and removes the filter from the table
===============================================================
*/
const applyFilters = useCallback(() => {
if (tableRef.current && tableRef.current.hotInstance) {
const hot = tableRef.current.hotInstance;
const filters = hot.getPlugin("filters");
filters.clearConditions();
if (filterValues.value !== "") {
filters.addCondition(filterValues.col, "contains", [filterValues.value]);
}
filters.filter();
hot.render();
}
}, [filterValues]);
useEffect(() => {
const debouncedApply = debounce(() => {
applyFilters();
}, 500);
debouncedApply();
}, [applyFilters, filterValues]);
function resetTableFilter() {
if (tableRef.current && tableRef.current.hotInstance) {
clearFilters();
}
setFilterValues({ col: 0, value: "" });
}
/**
===============================================================
* Add a new row to the table
* This function is called when the user clicks the add row button
===============================================================
*/
function addNewRow() {
const newRowId = bom.length > 0 ? +bom[bom.length - 1].id + 1 : 1;
const newRow: BOMItem = getNewRow(newRowId);
addBomItem(newRow, newRowId);
setHasChanged(true); // Set the state to true when a new row is added
}
/**
===============================================================
* Save the table data
===============================================================
*/
async function handleSave() {
if (tableRef.current && tableRef.current.hotInstance) {
const hot = tableRef.current?.hotInstance;
const data = hot?.getSourceDataArray();
if (!colHeaders || colHeaders.length === 0) {
toast.error("Column headers are missing. Cannot generate CSV.");
return;
}
setIsSaving(true);
const stylesData = transformDataToObjectsAndRemoveEmpty(styles);
try {
const csvString = convertToCsv(colHeaders, data);
const stylesRowsAsArrayOfArrays = stylesData.rows.map((obj) =>
stylesData.headers.map((header) => obj[header])
);
const csvStringStyles = convertToCsv(
stylesData.headers,
stylesRowsAsArrayOfArrays
);
const csvBlob = new Blob([csvString], { type: "text/csv" });
const csvBlobStyles = new Blob([csvStringStyles], { type: "text/csv" });
const formData = new FormData();
formData.append(
"csvFile",
csvBlob,
`bom-upload-${new Date().toISOString().split("T")[0]}.csv`
);
formData.append(
"csvFile",
csvBlobStyles,
`styles-bom-${new Date().toISOString().split("T")[0]}.csv`
);
const response = await axiosCall({
method: "POST",
urlPath: `/items/bulk-upload-csv`,
data: formData,
contentType: "multipart/form-data",
});
toast.success(
response.data.message || "Table data uploaded and processed successfully!"
);
setIsSaving(false);
setHasChanged(false);
} catch (error) {
setIsSaving(false);
console.error("Data upload failed:", error);
toast.error("An unexpected error occurred during data upload.");
}
}
}
/**
===============================================================
* Update data from stream and handle loading and error
===============================================================
*/
useEffect(() => {
if (data && data.length > 0) {
setBom(data[0]);
}
}, [data, setBom]);
/**
===============================================================
* Prevent window from closing if there are unsaved changes
===============================================================
*/
useUnsavedChangesProtection(hasChanged);
/**
===============================================================
* Handle loading and error
===============================================================
*/
if (loading || error) {
return (
<Container>
{error && <p>Error: {error}</p>}
{loading && <ContentLoadingSpinner title="Loading Data..." />}
</Container>
);
}
return (
<div className="pb-12 px-6 mx-auto">
<Card className="border-b-0 rounded-b-none -z-10">
<CardHeader>
<CardTitle>Bill of Material Items</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 lg:gap-0 lg:grid-cols-[4fr_8fr] justify-between items-end">
<SearchColumns
colHeaders={colHeaders}
filterValues={filterValues}
setFilterValues={setFilterValues}
reset={resetTableFilter}
/>
<div className="flex gap-4 lg:justify-self-end">
<Button variant="outline" size="md" onClick={() => setShowSaveModal(true)}>
<SaveIcon className="mr-2 size-4" />
Save As Template
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={isSaving || !hasChanged}
>
<SaveIcon className="mr-2 size-4" />
{isSaving ? "Saving..." : "Save"}
</Button>
<Button
variant="outline"
size="md"
className="border-green-500 text-green-500"
onClick={() => exportCSV(tableRef)}
>
<FileOutput className="mr-2 size-4" />
Export CSV
</Button>
<Button
variant="outline"
size="md"
className="border-slate-900 text-slate-900"
onClick={addNewRow}
>
<Table2Icon className="mr-2 size-4" />
Add Row
</Button>
<ShowHideColumns tableRef={tableRef} />
</div>
</div>
<TableFilters tableRef={tableRef} />
</CardContent>
</Card>
<DataTable
id="main-bom-table"
data={bom}
tableRef={tableRef}
colHeaders={colHeaders}
colConfig={ColumnConfig}
styles={styles}
setStyles={setStyles}
setTableData={setBom}
refetchStyles={refetch}
setHasChanged={setHasChanged}
hiddenColumns={hiddenColumns}
/>
<SaveTemplateModal
open={showSaveModal}
onOpenChange={(open) => setShowSaveModal(open)}
/>
</div>
);
}
Component:
import { HyperFormula } from "hyperformula";
import { registerAllModules } from "handsontable/registry";
import { HotTable, HotTableRef } from "@handsontable/react-wrapper";
import { RefObject, useCallback, useEffect } from "react";
import { BOMItem, HandleMetaChange, RowStyles } from "@/types";
import { useTheme } from "next-themes";
import useContextMenu from "@/components/bom/tables/use-context-menu";
import { CellChange, RowObject } from "handsontable/common";
import { ColumnRenderer } from "./column-renderer";
import { getColumnByIndex } from "../functions";
import { useBomStore } from "@/stores/use-bom-store";
import { ColConfigType } from "../config/columns";
type DataTableProps = {
id: string;
data: BOMItem[];
tableRef: RefObject<HotTableRef>;
colHeaders: string[];
styles: RowStyles;
colConfig: ColConfigType[];
hiddenColumns: number[];
setStyles: (styles: RowStyles) => void;
setTableData: (data: BOMItem[]) => void;
refetchStyles: () => void;
calculateTotalCost?: () => number;
setHasChanged: (hasChanged: boolean) => void;
};
// REGISTER HANDSONTABLE MODULES
registerAllModules();
const hyperformulaInstance = HyperFormula.buildEmpty({
licenseKey: "internal-use-in-handsontable",
});
export default function DataTable({
id,
data,
tableRef,
colHeaders,
styles,
colConfig,
hiddenColumns,
setStyles,
calculateTotalCost,
setHasChanged,
}: DataTableProps) {
const { theme } = useTheme();
const { updateBomItem } = useBomStore();
const { ContextMenuConfig } = useContextMenu({
tableRef,
});
/**
===============================================================
* On Theme change, update the theme of the table
===============================================================
*/
useEffect(() => {
const hot = tableRef.current.hotInstance;
if (!hot) return;
hot.useTheme(theme === "light" ? "ht-theme-main" : "ht-theme-main-dark");
hot.render();
}, [tableRef, theme]);
/**
===============================================================
Handle Table Column Changes
===============================================================
*/
const handleChange = useCallback(
(change: CellChange[]) => {
if (!change) return;
const hot = tableRef.current.hotInstance;
change.forEach(([_rowIndex, prop, oldVal, newVal]) => {
if (oldVal == newVal) return;
const physicalRowIndex = hot.toPhysicalRow(_rowIndex);
const dataRow: RowObject = hot.getSourceDataAtRow(physicalRowIndex);
// Update the BOM item
updateBomItem(physicalRowIndex, dataRow as BOMItem);
// Calculate and update total cost if quantity or unitCost changes
if (prop === "quantity" || prop === "unitCost") calculateTotalCost?.();
});
setHasChanged(true); // Set the state to true when a cell is changed
},
[tableRef, setHasChanged, updateBomItem, calculateTotalCost]
);
/**
===============================================================
Handle Table Styles Changes
===============================================================
*/
const updateCellStyle = useCallback(
(itemId: number, column: string, newStyle: any) => {
const style = (styles[itemId] = { ...styles[itemId], [column]: newStyle });
setStyles({ ...styles, [itemId]: style });
},
[styles, setStyles]
);
const handleMetaChange: HandleMetaChange = useCallback(
(row, column, key, value) => {
if (key == "backgroundColor") {
const colName = getColumnByIndex(column);
updateCellStyle(row, colName, { backgroundColor: value });
setHasChanged(true);
}
},
[updateCellStyle, setHasChanged]
);
/**
===============================================================
* Handle Remove Row
===============================================================
*/
function handleRemoveRow() {
setHasChanged(true);
// Recalculate total cost after removing row
if (calculateTotalCost) {
setTimeout(() => {
calculateTotalCost();
}, 100);
}
}
return (
<div className={theme === "light" ? "ht-theme-main" : "ht-theme-main-dark"}>
<HotTable
id={id}
ref={tableRef}
data={data}
height={600}
multiColumnSorting={true}
filters={true}
rowHeaders={true}
headerClassName="htLeft"
manualRowMove={true}
autoWrapRow={true}
autoWrapCol={true}
manualRowResize={true}
manualColumnResize={true}
navigableHeaders={true}
viewportRowRenderingOffset={50}
persistentState={true}
licenseKey="non-commercial-and-evaluation"
contextMenu={ContextMenuConfig}
colHeaders={colHeaders}
dropdownMenu={["filter_by_condition", "filter_by_value", "filter_action_bar"]}
formulas={{
engine: hyperformulaInstance,
}}
allowInvalid={false}
afterChange={handleChange}
afterSetCellMeta={handleMetaChange}
columns={colConfig.map((col) => ({
data: col.name,
type: col.type,
renderer: function (instance, td, row, col, prop, value) {
ColumnRenderer(instance, td, row, col, prop, value, styles);
},
readOnly: col.readOnly,
}))}
hiddenColumns={{ columns: hiddenColumns }}
afterRemoveRow={handleRemoveRow}
></HotTable>
</div>
);
}
context menu:
import { ContextMenu, Settings } from "handsontable/plugins/contextMenu";
import { colors } from "@/components/bom/config/colors";
import { RefObject } from "react";
import { HotTableRef } from "@handsontable/react-wrapper";
import { getColumnByIndex, getNewRow } from "../functions";
import { useBomStore } from "@/stores/use-bom-store";
type Props = {
tableRef: RefObject<HotTableRef>;
};
export default function useContextMenu({ tableRef }: Props) {
const { addBomItem, addStyle, removeBomItem } = useBomStore();
function changeCellBgColor(color: string) {
const hot = tableRef.current?.hotInstance;
if (!hot) return;
const selected = hot.getSelectedRangeLast();
if (!selected) return;
const startRow = selected.from.row;
const endRow = selected.to.row;
const startCol = selected.from.col;
const endCol = selected.to.col;
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
hot.setCellMeta(row, col, "backgroundColor", color);
const currentCell = hot.getCell(row, col);
currentCell.setAttribute("style", `background-color: ${color} !important;`);
addStyle(row, getColumnByIndex(col), { backgroundColor: color });
}
}
if (color) {
const styleId = `custom-bg-style-${color
.replace("#", "")
.replace(/[^a-zA-Z0-9]/g, "")}`;
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `.custom-bg-${color
.replace("#", "")
.replace(/[^a-zA-Z0-9]/g, "")} { background-color: ${color} !important; }`;
document.head.appendChild(style);
}
}
hot.render();
}
const ContextMenuConfig: Settings = {
items: {
row_above: {
name: "Insert row above",
callback: function (_key, hot) {
if (hot) {
const row = hot.at(0).start.row;
const newRow = getNewRow(row);
addBomItem(newRow, row);
this.alter("insert_row_above", row, 1);
}
},
},
row_below: {
name: "Insert row below",
callback: function (_key, hot) {
if (hot) {
const row = hot.at(0).start.row;
const newRow = getNewRow(row);
addBomItem(newRow, row + 1);
this.alter("insert_row_below", row + 1, 1);
}
},
},
remove_row: {
name: "Remove row",
callback: function (_key, hot) {
if (hot) {
const rowIds = [];
const startRow = hot.at(0).start.row;
const endRow = hot.at(0).end.row;
for (let row = startRow; row <= endRow; row++) {
rowIds.push(row);
}
// Remove from highest index to lowest to avoid shifting issues
rowIds
.sort((a, b) => b - a)
.forEach((row) => {
removeBomItem(row);
this.alter("remove_row", row);
});
}
},
},
separator: ContextMenu.SEPARATOR,
background_color: {
name: "Background color",
submenu: {
items: colors.map((color) => ({
key: `background_color:${color.key}`,
name: `<span class="w-4 h-4 inline-block mr-2 border border-gray-300 ${
!color.value
? "bg-gradient-to-br from-transparent via-transparent to-gray-300"
: ""
}" ${
color.value
? `style="background-color: ${color.value};"`
: 'style="background: repeating-linear-gradient(45deg, transparent, transparent 2px, #ccc 2px, #ccc 4px);"'
}></span>${color.key}`,
callback: function (key, hot) {
if (hot) {
const colorValue = colors.find(
(c) => `background_color:${c.key}` === key
)?.value;
if (colorValue !== undefined) {
changeCellBgColor(colorValue);
}
}
},
})),
},
},
clear_background: {
name: "Clear background",
callback: function (_key, hot) {
if (hot) {
changeCellBgColor("");
}
},
},
},
};
return { ContextMenuConfig };
}
Store:
import { BOMItem, RowStyles } from "@/types";
import { create } from "zustand";
interface BomStore {
bom: BOMItem[];
styles: RowStyles;
setBom: (bom: BOMItem[]) => void;
setStyles: (styles: RowStyles) => void;
addBomItem: (item: BOMItem, index?: number) => void;
updateBomItem: (index: number, item: BOMItem) => void;
removeBomItem: (index: number) => void;
addStyle: (rowIndex: number, fieldName: string, style: any) => void;
}
export const useBomStore = create<BomStore>((set) => ({
bom: [],
styles: {},
setBom: (bom) => set({ bom }),
setStyles: (styles) => set({ styles }),
addBomItem: (item: BOMItem, index?: number) =>
set((state) => {
if (index !== undefined) {
const newBom = [...state.bom];
newBom.splice(index, 0, item);
// Update styles to account for the new item being inserted
const newStyles: RowStyles = {};
// Copy styles for rows before the insertion point
for (let i = 0; i < index; i++) {
if (state.styles[i]) {
newStyles[i] = state.styles[i];
}
}
// Add empty styles for the new item at the insertion point
newStyles[index] = {};
// Shift styles for rows after the insertion point
Object.keys(state.styles).forEach((rowIndexStr) => {
const rowIndex = parseInt(rowIndexStr);
if (rowIndex >= index) {
newStyles[rowIndex + 1] = state.styles[rowIndex];
}
});
return { bom: newBom, styles: newStyles };
}
// If no index specified, add to the end with empty styles
const newBom = [...state.bom, item];
const newStyles = {
...state.styles,
[state.bom.length]: {},
};
return { bom: newBom, styles: newStyles };
}),
updateBomItem: (index: number, item: BOMItem) =>
set((state) => {
const newBom = [...state.bom];
newBom[index] = item;
return { bom: newBom };
}),
removeBomItem: (index: number) =>
set((state) => {
const newBom = [...state.bom];
newBom.splice(index, 1);
// Update styles to account for the removed item
const newStyles: RowStyles = {};
// Copy styles for rows before the removal point
for (let i = 0; i < index; i++) {
if (state.styles[i]) {
newStyles[i] = state.styles[i];
}
}
// Shift styles for rows after the removal point
Object.keys(state.styles).forEach((rowIndexStr) => {
const rowIndex = parseInt(rowIndexStr);
if (rowIndex > index) {
newStyles[rowIndex - 1] = state.styles[rowIndex];
}
});
return { bom: newBom, styles: newStyles };
}),
addStyle: (rowIndex: number, fieldName: string, style: any) =>
set((state) => ({
styles: {
...state.styles,
[rowIndex]: {
...state.styles[rowIndex],
[fieldName]: style,
},
},
})),
}));