From 158f11d58c56911d4a8b0b2f0686b2159a9807ce Mon Sep 17 00:00:00 2001 From: myoualid Date: Tue, 10 Sep 2024 22:48:22 +0100 Subject: [PATCH] Cost web ui improvements: - hovering cost item row displays quick actions - clicking the quantity cells and cost cells prompts form to edit values - further style improvements & refactoring --- src/bonsai/bonsai/bim/data/webui/sioserver.py | 6 +- .../bonsai/bim/data/webui/static/css/cost.css | 37 ++- .../bonsai/bim/data/webui/static/js/cost.js | 70 ++--- .../data/webui/static/js/utilities/costui.js | 262 +++++++++++++----- src/bonsai/bonsai/tool/web.py | 12 +- 5 files changed, 265 insertions(+), 122 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index 91cec745e5..e80173c474 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -183,10 +183,10 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_messages[sid]["predefined_types"] = data await sio.emit("predefined_types", {"blenderId": sid, "data": data}, namespace="/web") - async def on_selected_products(self, sid, data): + async def on_quantities(self, sid, data): print(f"Selected products from Blender client {sid}") - blender_messages[sid]["selected_products"] = data - await sio.emit("selected_products", {"blenderId": sid, "data": data}, namespace="/web") + blender_messages[sid]["quantities"] = data + await sio.emit("quantities", {"blenderId": sid, "data": data}, namespace="/web") async def schedules(request): with open("templates/index.html", "r") as f: diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css index bf2a85a9df..c0ad9a8e16 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css @@ -53,7 +53,6 @@ body { margin-bottom: 10px; } - .form-container { padding: var(--padding-medium); overflow-y: auto; @@ -155,7 +154,6 @@ body { table-layout: fixed; } - [id^="selected-products"] .form-container { height: 50vh; } @@ -186,7 +184,6 @@ tbody { td, th { - border-left: 1px solid #ddd; border-right: 1px solid #ddd; text-align: center; } @@ -304,8 +301,38 @@ form table { max-height: 50%; } - .subtotal-row { background-color: var(--button-hover-background); font-weight: bold; -} \ No newline at end of file +} + +[id^="cost-items"] th:nth-child(1), +[id^="cost-items"] td:nth-child(1) { + width: auto; +} +[id^="cost-items"] th:not(:nth-child(1)), +[id^="cost-items"] td:not(:nth-child(1)) { + width: 10%; +} + +[id^="cost-items"] th:last-child, +[id^="cost-items"] td:last-child { + width: fit-content; + min-width: fit-content; + max-width: 50%; +} + +.actions-column { + transition: opacity 0.3s ease-in-out; + opacity: 0; +} + +[id^="cost-items"] tr:hover .actions-column { + opacity: 1; +} + +.clickable-cell:hover { + border: 1px solid #28a746; + border-radius: 6px; + cursor: pointer; +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js index 22fca33d5a..74943c9426 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -28,53 +28,25 @@ function connectSocket() { socket.on("cost_items", handleCostItemsData); socket.on("cost_values", handleCostValuesData); socket.on("cost_value", handleCostValueData); - socket.on("selected_products", handleSelectedProducts); + socket.on("quantities", handleEditQuantities); } -function handleSelectedProducts(data) { - const costItemId = data.data["selected_products"]["cost_item_id"]; - const products = data.data["selected_products"]["selected_products"]; - const assigned_products = data.data["selected_products"]["assigned_products"]; - const quantityNames = - data.data["selected_products"]["product_quantity_names"]; - const formId = "selected-products-" + costItemId; - const form = CostUI.Form({ - id: formId, - name: "Edit Product Assignments for: " + CostUI.getCostItemName(costItemId), - icon: "fa-solid fa-box", - }); - const numberOfProducts = CostUI.Text( - "Selection basket : " + products.length + " products", - "fa-solid fa-cart-shopping", - "large" - ); - form.appendChild(numberOfProducts); - CostUI.highlightElement(costItemId); - const selectedProductsTable = CostUI.createProductTable({ - form, - products, - quantityNames, - costItemId, +function handleEditQuantities(data) { + const costItemId = data.data["quantities"]["cost_item_id"]; + const products = data.data["quantities"]["selected_products"]; + const assigned_products = data.data["quantities"]["assigned_products"]; + const quantityNames = data.data["quantities"]["product_quantity_names"]; + + CostUI.editQuantities({ + costItemId: costItemId, + selectedProducts: products, + assignedProducts: assigned_products, + quantityNames: quantityNames, callbacks: { addProductAssignments: addProductAssignments, - getSelectedProducts: getSelectedProducts, + enableEditingQuantities: enableEditingQuantities, }, }); - - if (assigned_products.length > 0) { - const assignedProductsText = CostUI.Text( - "Assigned Products", - "fa-solid fa-solid fa-paperclip", - "large" - ); - form.appendChild(assignedProductsText); - const assignmentsTable = CostUI.createAssignmentsTable({ - form, - products: assigned_products, - quantityNames, - costItemId, - }); - } } function handlePredefinedTypes(data) { @@ -322,9 +294,6 @@ function handleCostSchedulesData(data) { if (costSchedules.length === 0) { return; } - const currency = data.data["cost_schedules"]["currency"] - ? data.data["cost_schedules"]["currency"]["name"] - : "Undefined"; const costScheduleDiv = document.getElementById("cost-schedules"); costScheduleDiv.innerHTML = ""; costSchedules.forEach((costSchedule) => { @@ -368,8 +337,13 @@ function handleCostItemsData(data) { loadedSchedules[data.blenderId] = costScheduleId; CostUI.highlightElement("schedule-" + costScheduleId); + const currency = data.data["cost_items"]["currency"] + ? data.data["cost_items"]["currency"]["name"] + : "Undefined"; + CostUI.createCostSchedule({ - data: data.data["cost_items"]["cost_items"], + costItems: data.data["cost_items"]["cost_items"], + currency: currency, costScheduleId: costScheduleId, blenderID: data.blenderId, callbacks: { @@ -380,13 +354,13 @@ function handleCostItemsData(data) { editCostItemName: editCostItemName, enableEditingCostValues: enableEditingCostValues, addSummaryCostItem: addSummaryCostItem, - getSelectedProducts: getSelectedProducts, + enableEditingQuantities: enableEditingQuantities, }, }); } -function getSelectedProducts(costItemId) { - executeOperator({ type: "getSelectedProducts", costItemId: costItemId }); +function enableEditingQuantities(costItemId) { + executeOperator({ type: "enableEditingQuantities", costItemId: costItemId }); } function duplicateCostItem(costItemId) { diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js index a3f6c76882..0a94fb4d50 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -9,7 +9,7 @@ export class CostUI { static removeCostSchedule(id) { document.getElementById("cost-items-" + id).remove(); } - static createTable(id, callbacks) { + static createCostTable(id, currency, callbacks) { CostUI.isCostScheduleLoaded(id) ? CostUI.removeCostSchedule(id) : null; const table = document.createElement("table"); @@ -21,9 +21,9 @@ export class CostUI { "Name", "Quantity", "Unit", - "Cost", - "Total Cost", - "Action", + "Cost (" + currency + ")", + "Total Cost (" + currency + ")", + "Actions", ]; const thead = document.createElement("thead"); const tr = document.createElement("tr"); @@ -47,7 +47,6 @@ export class CostUI { table.appendChild(tbody); document.getElementById("cost-items").appendChild(table); - CostUI.addTableStyles(id); CostUI.createContextMenu(callbacks); table.get_blender_id = function () { return this.getAttribute("id").split("-")[2]; @@ -56,20 +55,12 @@ export class CostUI { return [table, tbody]; } - static addTableStyles(id) { - const style = document.createElement("style"); - style.textContent = ` - #cost-items-${id} th:nth-child(1), - #cost-items-${id} td:nth-child(1) { - width: auto; - } - #cost-items-${id} th:not(:nth-child(1)), - #cost-items-${id} td:not(:nth-child(1)) { - width: 100px; /* Set a fixed width for other columns */ - } - - `; - document.head.appendChild(style); + static deleteCostItem(costItemId, callback) { + const costItemRow = document.getElementById(costItemId); + let expandedState = JSON.parse(localStorage.getItem("expandedState")) || {}; + expandedState = CostUI.deleteCostItemRow(costItemRow, expandedState); + localStorage.setItem("expandedState", JSON.stringify(expandedState)); + callback(costItemId); } static createContextMenu(callbacks) { @@ -139,21 +130,17 @@ export class CostUI { addButton.dataset.listenerAdded = "true"; } - const deleteCostItem = document.getElementById("delete-cost-item"); - if (deleteCostItem && !deleteCostItem.hasListener) { - deleteCostItem.addEventListener("click", function () { + const deleteCostItemButton = document.getElementById("delete-cost-item"); + if (deleteCostItemButton && !deleteCostItemButton.hasListener) { + deleteCostItemButton.addEventListener("click", function () { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { const costItemId = parseInt(targetRow.getAttribute("id")); - let expandedState = - JSON.parse(localStorage.getItem("expandedState")) || {}; - expandedState = CostUI.deleteCostItemRow(targetRow, expandedState); - localStorage.setItem("expandedState", JSON.stringify(expandedState)); - callbacks.deleteCostItem(costItemId); + CostUI.deleteCostItem(costItemId, callbacks.deleteCostItem); } document.getElementById("context-menu").style.display = "none"; }); - deleteCostItem.hasListener = true; + deleteCostItemButton.hasListener = true; } const duplicateButton = document.getElementById("duplicate-button"); @@ -175,7 +162,7 @@ export class CostUI { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { const costItemId = parseInt(targetRow.getAttribute("id")); - callbacks.getSelectedProducts(costItemId); + callbacks.enableEditingQuantities(costItemId); } document.getElementById("context-menu").style.display = "none"; }); @@ -192,7 +179,7 @@ export class CostUI { document .getElementById("cost-items") - .addEventListener("dblclick", function (event) { + .addEventListener("click", function (event) { const targetRow = event.target.closest("tr"); const targetCell = event.target.closest("td"); if (targetRow && targetCell) { @@ -202,11 +189,16 @@ export class CostUI { const costItemId = parseInt(targetRow.getAttribute("id")); const columnName = getColumnNames("cost-items")[columnIndex]; - if (columnName === "Cost") { + if (columnName.includes("Cost") && !columnName.includes("Total")) { callbacks.enableEditingCostValues ? callbacks.enableEditingCostValues(costItemId) : null; } + if (columnName === "Quantity") { + callbacks.enableEditingQuantities + ? callbacks.enableEditingQuantities(costItemId) + : null; + } } }); } @@ -363,13 +355,18 @@ export class CostUI { } static createCostSchedule({ - data, + costItems, + currency, costScheduleId, blenderID, callbacks = {}, }) { - const [table, tbody] = CostUI.createTable(blenderID, callbacks); - if (data.length === 0) { + const [table, tbody] = CostUI.createCostTable( + blenderID, + currency, + callbacks + ); + if (costItems.length === 0) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 6; @@ -387,24 +384,29 @@ export class CostUI { td.appendChild(addSummaryCostItemButton); addSummaryCostItemButton.classList.add("action-button"); } else { - CostUI.createCostItem(data, tbody, 0, null, callbacks); + CostUI.createCostTree(costItems, tbody, 0, null, callbacks); CostUI.applyExpandedState(); } } - static createCostItem( - data, + static createCostTree( + costItems, container, nestingLevel = 0, parentID = null, callbacks = {} ) { - data.forEach((costItem) => { - const row = CostUI.createRow(costItem, nestingLevel, parentID, callbacks); + costItems.forEach((costItem) => { + const row = CostUI.addCostItemRow( + costItem, + nestingLevel, + parentID, + callbacks + ); container.appendChild(row); if (costItem.is_nested_by && costItem.is_nested_by.length > 0) { - CostUI.createCostItem( + CostUI.createCostTree( costItem.is_nested_by, container, nestingLevel + 1, @@ -415,7 +417,7 @@ export class CostUI { }); } - static createRow(costItem, nestingLevel, parentID, callbacks = {}) { + static addCostItemRow(costItem, nestingLevel, parentID, callbacks = {}) { const totalQuantity = costItem.TotalCostQuantity ? parseFloat(costItem.TotalCostQuantity).toFixed(2) : "-"; @@ -431,20 +433,21 @@ export class CostUI { callbacks ); const totalCostQuantityCell = CostUI.createTableCell(totalQuantity); + totalCostQuantityCell.classList.add("clickable-cell"); const unitSymbolCell = CostUI.createTableCell(costItem.UnitSymbol); const totalAppliedValueCell = CostUI.createTableCell(appliedValue); + totalAppliedValueCell.classList.add("clickable-cell"); const totalCostCell = CostUI.createTotalCostCell(costItem); - const flexContainerCell = CostUI.createFlexContainerCell( - costItem, - callbacks - ); + const actionsCell = CostUI.costItemActions(costItem, callbacks); + + actionsCell.classList.add("actions-column"); row.appendChild(nameCell); row.appendChild(totalCostQuantityCell); row.appendChild(unitSymbolCell); row.appendChild(totalAppliedValueCell); row.appendChild(totalCostCell); - row.appendChild(flexContainerCell); + row.appendChild(actionsCell); row.get_id = function () { return this.getAttribute("id"); @@ -636,30 +639,92 @@ export class CostUI { return totalCostCell; } - static createFlexContainerCell(costItem, callbacks) { + static costItemActions(costItem, callbacks) { const divFlex = document.createElement("div"); divFlex.classList.add("row-container"); - const selectButton = CostUI.createSelectButton(costItem, callbacks); - divFlex.appendChild(selectButton); + const addCostItem = CostUI.addCostItemButton( + costItem.id, + callbacks.addCostItem + ); + const selectButton = CostUI.createSelectButton( + costItem.id, + callbacks.selectAssignedElements + ); + const deleteButton = CostUI.deleteCostItemButton( + costItem.id, + callbacks.deleteCostItem + ); + const duplicateButton = CostUI.duplicateCostItemButton( + costItem.id, + callbacks.duplicateCostItem + ); - const flexContainerCell = document.createElement("td"); - flexContainerCell.appendChild(divFlex); - return flexContainerCell; + [addCostItem, duplicateButton, selectButton, deleteButton].forEach( + (button) => { + divFlex.appendChild(button); + } + ); + + const actionsCell = document.createElement("td"); + actionsCell.appendChild(divFlex); + return actionsCell; } - static createSelectButton(costItem, callbacks) { - const selectButton = document.createElement("button"); - selectButton.classList.add("action-button"); - selectButton.textContent = "Select"; - selectButton.addEventListener("click", function (e) { - e.stopPropagation(); - callbacks.selectAssignedElements - ? callbacks.selectAssignedElements(costItem.id) - : null; - }); + static duplicateCostItemButton(costItemId, duplicateCostItem) { + const duplicateButton = CostUI.createButton( + "Duplicate", + "fa-solid fa-copy" + ); + duplicateButton.addEventListener( + "click", + duplicateCostItem.bind(null, costItemId) + ); + return duplicateButton; + } + + static addCostItemButton(costItemId, addCostItem) { + const addCostItemButton = CostUI.createButton( + "Add Sub-Cost", + "fa-solid fa-plus" + ); + addCostItemButton.addEventListener( + "click", + addCostItem.bind(null, costItemId) + ); + return addCostItemButton; + } + + static createSelectButton(costItemId, selectAssignedElements) { + const selectButton = CostUI.createButton( + "Select", + "fa-solid fa-arrow-pointer" + ); + selectButton.addEventListener( + "click", + selectAssignedElements.bind(null, costItemId) + ); return selectButton; } + static deleteCostItemButton(costItemId, deleteCostItem) { + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + deleteButton.addEventListener( + "click", + CostUI.deleteCostItem.bind(null, costItemId, deleteCostItem) + ); + return deleteButton; + } + + static createButton(text, icon) { + const button = document.createElement("button"); + !icon ? (button.textContent = text) : null; + icon + ? button.classList.add("action-button", ...icon.split(" ")) + : button.classList.add("action-button"); + button.dataset.tooltip = text; + return button; + } + static highlightElement(id) { const element = document.getElementById(id); if (element) { @@ -1479,6 +1544,7 @@ export class CostUI { const picker = CostUI.createColorPicker(); const tableFontSize = CostUI.createFontSizePicker(); + const currencyPicker = CostUI.createCurrencyPicker(); settingsMenu.appendChild(picker); settingsMenu.appendChild(tableFontSize); document.getElementById("settings-menu").style.display = "none"; @@ -1521,6 +1587,32 @@ export class CostUI { return div; } + static createCurrencyPicker() { + const currency = CostUI.getCurrency(); + const div = document.createElement("div"); + const currencyText = document.createElement("p"); + currencyText.textContent = "Select a currency for the table:"; + div.appendChild(currencyText); + const currencyPicker = document.createElement("input"); + currencyPicker.type = "text"; + currencyPicker.id = "currency-picker"; + currencyPicker.value = currency; + div.appendChild(currencyPicker); + currencyPicker.addEventListener("input", function () { + const currency = currencyPicker.value; + CostUI.saveCurrency(currency); + }); + return div; + } + + static saveCurrency(currency) { + localStorage.setItem("tableCurrency", currency); + } + + static getCurrency() { + return localStorage.getItem("tableCurrency"); + } + static saveFontSize(fontSize) { localStorage.setItem("tableFontSize", fontSize); } @@ -1637,4 +1729,50 @@ export class CostUI { }, }); } + + static editQuantities({ + costItemId, + selectedProducts, + quantityNames, + assignedProducts, + callbacks, + }) { + const formId = "selected-products-" + costItemId; + const form = CostUI.Form({ + id: formId, + name: + "Edit Product Assignments for: " + CostUI.getCostItemName(costItemId), + icon: "fa-solid fa-box", + }); + const numberOfProducts = CostUI.Text( + "Selection basket : " + selectedProducts.length + " products", + "fa-solid fa-cart-shopping", + "large" + ); + form.appendChild(numberOfProducts); + CostUI.highlightElement(costItemId); + const selectedProductsTable = CostUI.createProductTable({ + form, + products: selectedProducts, + quantityNames, + costItemId, + callbacks, + }); + + if (assignedProducts.length > 0) { + const assignedProductsText = CostUI.Text( + "Assigned Products", + "fa-solid fa-solid fa-paperclip", + "large" + ); + form.appendChild(assignedProductsText); + const assignmentsTable = CostUI.createAssignmentsTable({ + form, + products: assignedProducts, + quantityNames, + costItemId, + callbacks, + }); + } + } } diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 5408efd898..5a4df5e2b2 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -425,7 +425,7 @@ class Web(bonsai.core.tool.Web): cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) products = tool.Cost.get_cost_item_products(cost_item, is_deep=True) tool.Spatial.select_products(products, unhide=True) - if operator_data["type"] == "getSelectedProducts": + if operator_data["type"] == "enableEditingQuantities": cost_item_id = operator_data["costItemId"] cost_item = ifc_file.by_id(cost_item_id) if not cost_item: @@ -446,8 +446,8 @@ class Web(bonsai.core.tool.Web): "product_quantity_names": names, "cost_item_id": cost_item_id, }, - data_key="selected_products", - event="selected_products", + data_key="quantities", + event="quantities", ) if operator_data["type"] == "addSummaryCostItem": @@ -531,7 +531,11 @@ class Web(bonsai.core.tool.Web): def load_cost_schedule_web_ui(cls, cost_schedule): json_data = tool.Cost.create_cost_schedule_json(cost_schedule) cls.send_webui_data( - data={"cost_items": json_data, "cost_schedule_id": cost_schedule.id()}, + data={ + "cost_items": json_data, + "cost_schedule_id": cost_schedule.id(), + "currency": tool.Cost.currency(), + }, data_key="cost_items", event="cost_items", )