From 80709bcce31d4ae825b91ed4b4a3b1081f46004d Mon Sep 17 00:00:00 2001 From: Pardhav Maradani Date: Mon, 22 Jun 2026 09:26:05 +0530 Subject: [PATCH] Add support to duplicate widgets --- CHANGELOG.md | 2 + mdadash/backend/kernel/core.py | 12 ++++++ mdadash/backend/kernel/manager.py | 27 ++++++++++++++ mdadash/backend/main.py | 23 +++++++++++- mdadash/backend/tests/test_server.py | 23 ++++++++++++ mdadash/backend/widgets/base.py | 37 ++++++++++++++++++- .../src/__tests__/views/DashboardView.spec.js | 17 +++++++++ mdadash/frontend/src/views/DashboardView.vue | 20 ++++++---- 8 files changed, 151 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f39fab7..dd95c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ The rules for this file: +- Added support to duplicate widgets (PR #13) + ### Fixed diff --git a/mdadash/backend/kernel/core.py b/mdadash/backend/kernel/core.py index 1f78cdf..2b40270 100644 --- a/mdadash/backend/kernel/core.py +++ b/mdadash/backend/kernel/core.py @@ -303,6 +303,15 @@ def add_instance(self, data: dict) -> dict: } ) + def duplicate_instance(self, data: dict) -> None: + """Duplicate widget instance based on instance uuid""" + uid = data.get("uid") + new_uuid = self._wm.duplicate_widget_instance(uid, data.get("uuid")) + if self._um._connected: + # set the universe for the new widget instance + self._wm._set_universe(uid, self._um._universes[uid], new_uuid) + self._comm_handler.send({"status": "ok", "uuid": new_uuid}) + def remove_instance(self, data: dict) -> dict: """Remove widget instance based on uuid""" uuid = self._wm.delete_widget_instance(data.get("uuid", None)) @@ -350,6 +359,9 @@ def init_n_universes(data: dict) -> None: "widgets:get_available_widgets", widgets_comm.get_available_widgets ) comm_handler.register_handler("widgets:add_instance", widgets_comm.add_instance) +comm_handler.register_handler( + "widgets:duplicate_instance", widgets_comm.duplicate_instance +) comm_handler.register_handler("widgets:remove_instance", widgets_comm.remove_instance) comm_handler.register_handler("widget:get_inputs", widgets_comm.get_inputs) comm_handler.register_handler("widget:set_input", widgets_comm.set_input) diff --git a/mdadash/backend/kernel/manager.py b/mdadash/backend/kernel/manager.py index 2b080f7..9242279 100644 --- a/mdadash/backend/kernel/manager.py +++ b/mdadash/backend/kernel/manager.py @@ -423,6 +423,33 @@ async def add_widget_instance(self, uid: int, widget_name: str) -> dict: "widgets:add_instance", {"uid": uid, "name": widget_name} ) + async def duplicate_widget_instance(self, uid: int, widget_uuid: str) -> dict: + """Duplicate widget instance + + Parameters + ---------- + uid: int + Universe ID (index into universes array) + + widget_uuid: str + UUID of the widget instance to be duplicated + + Returns + ------- + response: dict + Response dict indicating status. This has the following keys: + + status + String indication status: 'ok' or 'error' + + message + An error message string when status is 'error' + + """ + return await self.send_message_await_response( + "widgets:duplicate_instance", {"uid": uid, "uuid": widget_uuid} + ) + async def remove_widget_instance(self, widget_uuid: str) -> dict: """Remove widget instance diff --git a/mdadash/backend/main.py b/mdadash/backend/main.py index 63f8369..2eb6747 100644 --- a/mdadash/backend/main.py +++ b/mdadash/backend/main.py @@ -153,10 +153,11 @@ async def remove_widget(_sid, uuid): async def add_widget(_sid, uid, name, description): response = await km.add_widget_instance(uid, name) if response["status"] == "ok": + max_y = max(((w["y"] + w["h"]) for w in sm.widgets_layout), default=0) sm.widgets_layout.append( { "x": 0, - "y": 0, + "y": max_y, "w": 12, "h": 14, "i": response["uuid"], @@ -168,6 +169,26 @@ async def add_widget(_sid, uid, name, description): return response +@sio.on("widgets:duplicate_widget") +async def duplicate_widget(_sid, uid, uuid, name, description): + response = await km.duplicate_widget_instance(uid, uuid) + if response["status"] == "ok": + max_y = max(((w["y"] + w["h"]) for w in sm.widgets_layout), default=0) + sm.widgets_layout.append( + { + "x": 0, + "y": max_y, + "w": 12, + "h": 14, + "i": response["uuid"], + "name": f"Copy of {name}", + "description": description, + } + ) + await emit_layout() + return response + + @sio.on("dashboard:activated") async def dashboard_activated(sid=None): await km._emit_last_known_values(sid) diff --git a/mdadash/backend/tests/test_server.py b/mdadash/backend/tests/test_server.py index c9aef72..aec0a1d 100644 --- a/mdadash/backend/tests/test_server.py +++ b/mdadash/backend/tests/test_server.py @@ -263,6 +263,29 @@ async def test_add_remove_widgets(_client): assert response["status"] == "ok" +async def test_duplicate_widgets(_client): + # add a widget + handler = sio.handlers["/"]["widgets:add_widget"] + response = await run_task_until_done(handler("_sid", 0, "Absolute Temperature", "")) + uuid1 = response.get("uuid", None) + assert uuid1 is not None + # duplicate the widget + handler = sio.handlers["/"]["widgets:duplicate_widget"] + response = await run_task_until_done( + handler("_sid", 0, uuid1, "Absolute Temperature", "") + ) + uuid2 = response.get("uuid", None) + assert uuid2 is not None + # remove the original widget + handler = sio.handlers["/"]["widgets:remove_widget"] + response = await run_task_until_done(handler("_sid", uuid1)) + assert response["status"] == "ok" + # remove the duplicate widget + handler = sio.handlers["/"]["widgets:remove_widget"] + response = await run_task_until_done(handler("_sid", uuid2)) + assert response["status"] == "ok" + + async def test_update_layout(_client): handler = sio.handlers["/"]["widgets:update_layout"] response = await run_task_until_done(handler("_sid", [])) diff --git a/mdadash/backend/widgets/base.py b/mdadash/backend/widgets/base.py index 75e40d8..9a15a2e 100644 --- a/mdadash/backend/widgets/base.py +++ b/mdadash/backend/widgets/base.py @@ -218,10 +218,45 @@ def add_widget_instance(self, uid: int, widget_name: str) -> str | None: return uuid return None + def duplicate_widget_instance(self, uid: int, uuid: str) -> str | None: + """Duplicate widget instance + + Duplicate widget instance based on existing instance uuid + + Parameters + ---------- + uid: int + Universe ID (index into universes array) + + uuid: str + The uuid of the instance to be duplicated + + Returns + ------- + uuid of new instance created + + """ + # get existing instance + instance = self.instances[uuid] + # duplicate instance + widget_class = instance.__class__ + new_instance = widget_class() + setattr(new_instance, "uid", uid) + # set inputs for new instance + inputs = instance._get_inputs() + for _input in inputs: + attribute = _input["attribute"] + value = _input["value"] + setattr(new_instance, attribute, value) + # add new instance to instances list + new_uuid = str(uuid1()) + self.instances[new_uuid] = instance + return new_uuid + def delete_widget_instance(self, uuid: str) -> str | None: """Remove widget instance - Remove widget instanced based on uuid returned during + Remove widget instance based on uuid returned during the instance creation using :meth:`add_widget_instance` Parameters diff --git a/mdadash/frontend/src/__tests__/views/DashboardView.spec.js b/mdadash/frontend/src/__tests__/views/DashboardView.spec.js index cd35d12..af29aed 100644 --- a/mdadash/frontend/src/__tests__/views/DashboardView.spec.js +++ b/mdadash/frontend/src/__tests__/views/DashboardView.spec.js @@ -360,7 +360,24 @@ describe('DashboardView.vue', () => { // click on edit components[0].trigger('click') // click on duplicate + mockEmitWithAck.mockResolvedValueOnce({ uuid: 'uuid2' }) components[1].trigger('click') + await nextTick() + expect(mockTimeout).toHaveBeenCalledWith(5000) + expect(mockEmitWithAck).toHaveBeenCalledWith( + 'widgets:duplicate_widget', + 0, + 'uuid1', + 'name1', + 'desc1', + ) + // check page moves to widget view + expect(mockPush).toHaveBeenCalledWith({ + path: '/widget', + query: { + uuid: 'uuid2', + }, + }) // click on the delete action components[2].trigger('click') // check remove widget sent to server diff --git a/mdadash/frontend/src/views/DashboardView.vue b/mdadash/frontend/src/views/DashboardView.vue index ba3c863..2da9571 100644 --- a/mdadash/frontend/src/views/DashboardView.vue +++ b/mdadash/frontend/src/views/DashboardView.vue @@ -307,12 +307,9 @@ const gridPresetIcons = { editable: () => h('svg', { viewBox: '0 0 24 24' }, [ h('path', { - d: 'M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5ZM14 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V5ZM4 16a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-3ZM14 13a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-6Z', - fill: 'none', + d: 'M21 13.1C20.9 13.1 20.7 13.2 20.6 13.3L19.6 14.3L21.7 16.4L22.7 15.4C22.9 15.2 22.9 14.8 22.7 14.6L21.4 13.3C21.3 13.2 21.2 13.1 21 13.1M19.1 14.9L13 20.9V23H15.1L21.2 16.9L19.1 14.9M21 3H13V9H21V3M19 7H15V5H19V7M13 18.06V11H21V11.1C20.24 11.1 19.57 11.5 19.19 11.89L18.07 13H15V16.07L13 18.06M11 3H3V13H11V3M9 11H5V5H9V11M11 20.06V15H3V21H11V20.06M9 19H5V17H9V19Z', stroke: '#000000', - 'stroke-width': 2, - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', + 'stroke-width': 0.1, }), ]), col1: () => @@ -322,8 +319,6 @@ const gridPresetIcons = { fill: 'none', stroke: '#000000', 'stroke-width': 2, - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', }), ]), col2: () => @@ -510,7 +505,7 @@ async function handleAddWidgetClick(isOpen) { } } -function widgetFunction(item, action) { +async function widgetFunction(item, action) { // Handle widget actions if (action['title'] == 'Delete') { socket.emit('widgets:remove_widget', item.i) @@ -519,6 +514,15 @@ function widgetFunction(item, action) { path: '/widget', query: { uuid: item.i }, }) + } else { + // (action['title'] == 'Duplicate') + const response = await socket + .timeout(settings.value.dashboard_config.ui_request_timeout) + .emitWithAck('widgets:duplicate_widget', 0, item.i, item.name, item.description) + router.push({ + path: '/widget', + query: { uuid: response.uuid }, + }) } }