From 5ef1293f3b4e75e3b2fb48587185493275dc3331 Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 11:05:30 +0100
Subject: [PATCH 1/8] (ui): refactor folder structure for device view

---
 .../devices/view/device.view.list.tsx         | 122 ------------------
 .../components/devices/view/device.view.tsx   |   4 +-
 .../subcomponent/device.view.list-detail.tsx  |  87 -------------
 .../view_model/device.list.viewmodel.ts       | 104 ---------------
 .../devices/view_model/modal.viewmodel.ts     |  13 +-
 5 files changed, 11 insertions(+), 319 deletions(-)
 delete mode 100755 react-ui/src/components/devices/view/device.view.list.tsx
 delete mode 100644 react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx
 delete mode 100755 react-ui/src/components/devices/view_model/device.list.viewmodel.ts

diff --git a/react-ui/src/components/devices/view/device.view.list.tsx b/react-ui/src/components/devices/view/device.view.list.tsx
deleted file mode 100755
index 868f26959..000000000
--- a/react-ui/src/components/devices/view/device.view.list.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { insertMarkTags } from "@helper/text";
-import { useAppSelector } from "@hooks";
-import DOMPurify from "dompurify";
-import { RefObject, useCallback, useRef } from "react";
-import { Col, OverlayTrigger, Row, Tooltip } from "react-bootstrap";
-import { useTranslation } from "react-i18next";
-import { Device } from "../reducer/device.reducer";
-import { useDeviceTableViewModel } from "../view_model/device.list.viewmodel";
-
-const cropUUID = (uuid: string): string => {
-  return (
-    uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length)
-  );
-};
-
-export const DeviceList = ({
-  searchRef,
-}: {
-  searchRef: RefObject<HTMLInputElement>;
-}) => {
-  const {
-    devices,
-    pnds,
-    selected: selectedDevice,
-  } = useAppSelector((state) => state.device);
-  const { t } = useTranslation("common");
-  const listRef = useRef<HTMLDivElement>(null);
-  const { dispatchDevice } = useDeviceTableViewModel(searchRef, listRef);
-
-  const handleItemClick = useCallback((device: Device) => {
-    dispatchDevice(device);
-  }, []);
-
-  const getDeviceList = useCallback(() => {
-    const search = searchRef?.current?.value;
-    let filtered = devices;
-
-    if (search) {
-      filtered = devices.filter((device) => {
-        const user = pnds.find((pnd) => pnd.id === device.pid);
-        return (
-          device.id?.includes(search) ||
-          device.name?.includes(search) ||
-          user?.name?.includes(search)
-        );
-      });
-    }
-
-    return filtered.map((device) => {
-      const user = pnds.find((pnd) => pnd.id === device.pid);
-      const username = user?.name || "";
-      const deviceId = device.id!;
-      const croppedId = cropUUID(deviceId);
-      const devicename = device.name || "";
-      const isSelected = selectedDevice?.device.id === deviceId;
-
-      return (
-        <div
-          key={deviceId}
-          className={`border-bottom border-primary p-2 transitions ${isSelected && "bg-gradient-fade py-2"} ${!isSelected && "text-disabled disabled-hover"}`}
-          onClick={() => handleItemClick(device)}
-        >
-          <Row
-            className="align-items-center clickable"
-            onClick={() => handleItemClick(device)}
-          >
-            <Col xs={12} sm={5}>
-              <span
-                dangerouslySetInnerHTML={{
-                  __html: search
-                    ? insertMarkTags(devicename, search)
-                    : DOMPurify.sanitize(devicename),
-                }}
-              />
-            </Col>
-            <Col xs={12} sm={3}>
-              <OverlayTrigger
-                overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>}
-              >
-                <span
-                  className="text-gray-500"
-                  dangerouslySetInnerHTML={{
-                    __html: search
-                      ? insertMarkTags(croppedId, search)
-                      : DOMPurify.sanitize(croppedId),
-                  }}
-                />
-              </OverlayTrigger>
-            </Col>
-            <Col xs={12} sm={4}>
-              <span
-                className="text-gray-500"
-                dangerouslySetInnerHTML={{
-                  __html: search
-                    ? insertMarkTags(username, search)
-                    : DOMPurify.sanitize(username),
-                }}
-              />
-            </Col>
-          </Row>
-        </div>
-      );
-    });
-  }, [devices, searchRef, pnds, selectedDevice, handleItemClick]);
-
-  return (
-    <div className="rounded border border-primary mt-2">
-      <Row className="border-bottom border-primary px-2 py-2 mx-0">
-        <Col xs={12} sm={5}>
-          <span className="font-medium">{t("device.table.header.name")}</span>
-        </Col>
-        <Col xs={12} sm={3}>
-          <span className="font-medium">{t("device.table.header.uuid")}</span>
-        </Col>
-        <Col xs={12} sm={4}>
-          <span className="font-medium">{t("device.table.header.user")}</span>
-        </Col>
-      </Row>
-      <div ref={listRef}>{getDeviceList()}</div>
-    </div>
-  );
-};
diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx
index 814f51db1..2523e4ae1 100755
--- a/react-ui/src/components/devices/view/device.view.tsx
+++ b/react-ui/src/components/devices/view/device.view.tsx
@@ -12,9 +12,9 @@ import { useRef } from "react";
 import { Button, Col, Form, Row } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 import { useDeviceViewModel } from "../view_model/device.viewmodel";
+import { DeviceList } from "./boxes/devices.box.view";
+import { DeviceListCollapsable } from "./boxes/information.box.view";
 import "./device.scss";
-import { DeviceList } from "./device.view.list";
-import { DeviceListCollapsable } from "./subcomponent/device.view.list-detail";
 import AddDeviceModal from "./subcomponent/modal.view";
 
 const DeviceView = () => {
diff --git a/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx b/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx
deleted file mode 100644
index ba146d782..000000000
--- a/react-ui/src/components/devices/view/subcomponent/device.view.list-detail.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { faChevronDown, faHashtag, faUser } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { insertMarkTags } from "@helper/text";
-import { useAppSelector } from "@hooks";
-import { JsonViewer } from "@shared/components/json_viewer/view/json_viewer.view";
-import DOMPurify from 'dompurify';
-import { useState } from "react";
-import { Collapse } from "react-bootstrap";
-
-interface DeviceListCollapsableProps {
-    search?: string,
-}
-
-enum Collapsables {
-    Metadata = 1,
-    Config = 2
-}
-
-export const DeviceListCollapsable = ({ search }: DeviceListCollapsableProps) => {
-    const { selected } = useAppSelector(state => state.device);
-    const [collapseable, setCollapsable] = useState<Collapsables | undefined>(undefined)
-
-    const username = selected?.device.name || "";
-    const deviceId = selected?.device.id || "";
-    const json = selected?.json || {}
-
-    const metadataKey = Object.keys(json).at(2) as keyof typeof json
-    const metadataObject = json[metadataKey] as JSON || {};
-
-    const configKey = Object.keys(json).at(0) as keyof typeof json
-    const configObject = json[configKey] as JSON || {};
-
-
-    const setCollapsed = (prev: Collapsables) => {
-        const next = collapseable === prev ? undefined : prev;
-        setCollapsable(next);
-    }
-
-    return (
-        <div id={`collapse-${deviceId}`}>
-            <div className="pb-4 pt-1 d-flex flex-column gap-1" >
-                <div className="d-flex justify-content-between">
-                    <div>
-                        <FontAwesomeIcon className="me-2" icon={faHashtag} />
-                        UUID:
-                    </div>
-                    <span dangerouslySetInnerHTML={{
-                        __html: search ? insertMarkTags(deviceId, search) : DOMPurify.sanitize(deviceId)
-                    }} />
-                </div>
-                <div className="d-flex justify-content-between">
-                    <div>
-                        <FontAwesomeIcon className="me-2" icon={faUser} />
-                        User:
-                    </div>
-                    <span>{username}</span>
-                </div>
-
-                <div className="d-flex justify-content-between clickable border-top border-dark mt-3 pt-3" aria-expanded={collapseable === Collapsables.Metadata} onClick={() => setCollapsed(Collapsables.Metadata)}>
-                    <div>
-                        <FontAwesomeIcon icon={faChevronDown} rotation={collapseable === Collapsables.Metadata ? undefined : 270} />
-                        Metadata
-                    </div>
-                </div>
-
-                <Collapse in={collapseable === Collapsables.Metadata}>
-                    <div id={`collapse-${deviceId}`}>
-                        {JsonViewer({ json: metadataObject, options: { editable: false, searchEnabled: false } })}
-                    </div>
-                </Collapse>
-
-                <div className="d-flex justify-content-between clickable mt-3" aria-expanded={collapseable === Collapsables.Config} onClick={() => setCollapsed(Collapsables.Config)}>
-                    <div>
-                        <FontAwesomeIcon icon={faChevronDown} rotation={collapseable === Collapsables.Config ? undefined : 270} />
-                        Config
-                    </div>
-                </div>
-
-                <Collapse in={collapseable === Collapsables.Config}>
-                    <div id={`collapse-${deviceId}`}>
-                        {JsonViewer({ json: configObject, options: { editable: false, searchEnabled: false } })}
-                    </div>
-                </Collapse>
-            </div>
-        </div >
-    )
-}
\ No newline at end of file
diff --git a/react-ui/src/components/devices/view_model/device.list.viewmodel.ts b/react-ui/src/components/devices/view_model/device.list.viewmodel.ts
deleted file mode 100755
index 77ba8ddea..000000000
--- a/react-ui/src/components/devices/view_model/device.list.viewmodel.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Device, setSelectedDevice } from "@component/devices/reducer/device.reducer";
-import { faCopy } from "@fortawesome/free-solid-svg-icons";
-import { useAppDispatch } from "@hooks";
-import { useMenu } from "@provider/menu/menu.provider";
-import { useUtils } from "@provider/utils.provider";
-import { useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { toast } from "react-toastify";
-
-export const useDeviceTableViewModel = (searchRef, listRef) => {
-    const [searchTerm, setSearchTerm] = useState('');
-    const dispatch = useAppDispatch();
-    const { subscribe } = useMenu();
-    const { toClipboard } = useUtils();
-    const { t } = useTranslation('common');
-
-
-    const registerMenuOptions = () => {
-        const subscription = subscribe!({
-            target: listRef.current,
-            actions: [
-                {
-                    key: t('device.table.actions.copy'),
-                    icon: faCopy,
-                    action: (clickedElement) => {
-                        if (clickedElement) {
-                            const text = clickedElement.dataset.copyValue
-                            if (!text) {
-                                toast.warn(t('global.toast.copied_failed'))
-                                return
-                            }
-
-
-                            toClipboard(text)
-                        }
-                    }
-                },
-
-                {
-                    key: t('device.table.actions.copy_row'),
-                    icon: faCopy,
-                    action: (clickedElement) => {
-                        let parent = clickedElement;
-                        while (parent && parent.tagName !== 'TR') {
-                            parent = parent.parentNode;
-                        }
-
-                        const text = parent.dataset.copyValue
-                        if (!text) {
-                            toast.warn(t('global.toast.copied_failed'))
-                            return
-                        }
-                        toClipboard(text)
-                    }
-                }
-            ]
-        })
-
-        return () => {
-            subscription.unsubscribe()
-        }
-    }
-
-    useEffect(() => {
-        if (!subscribe || !listRef?.current) {
-            return
-        }
-
-        const unsubscribe = registerMenuOptions()
-
-        return () => {
-            unsubscribe()
-        }
-    }, [listRef, subscribe])
-
-
-    useEffect(() => {
-        if (!searchRef?.current) {
-            return
-        }
-
-        const handleSearchChange = () => {
-            setSearchTerm(searchRef.current.value);
-        };
-
-        searchRef.current.addEventListener('input', handleSearchChange);
-
-        return () => {
-            if (searchRef.current) {
-                searchRef.current.removeEventListener('input', handleSearchChange);
-            }
-        };
-    }, [searchRef]);
-
-    const dispatchDevice = (device: Device) => {
-        dispatch(setSelectedDevice({ device }));
-    }
-
-
-    return {
-        searchTerm,
-        dispatchDevice
-    }
-}
\ No newline at end of file
diff --git a/react-ui/src/components/devices/view_model/modal.viewmodel.ts b/react-ui/src/components/devices/view_model/modal.viewmodel.ts
index 98742bd34..f5b12bec5 100644
--- a/react-ui/src/components/devices/view_model/modal.viewmodel.ts
+++ b/react-ui/src/components/devices/view_model/modal.viewmodel.ts
@@ -1,6 +1,6 @@
 import { NetworkelementAddListRequest, NetworkelementSetMne, useNetworkElementServiceAddListMutation } from "@api/api";
 import { useAppDispatch, useAppSelector } from "@hooks";
-import { fetchUser } from "@shared/routine/user.routine";
+import { fetchPnds, fetchUser } from "@shared/routine/user.routine";
 import { useState } from "react";
 import { SubmitHandler, useForm } from "react-hook-form";
 import { useTranslation } from "react-i18next";
@@ -42,7 +42,12 @@ export const useModalViewModel = ({ hide }: ModalViewModelType) => {
     const { user } = useAppSelector(state => state.user);
 
     const reset = () => { resetModal(); hide(); }
-    const success = () => { toast.success(t('device.add_device.success')); reset(); dispatch(fetchUser()) }
+    const success = () => {
+        toast.success(t('device.add_device.success'));
+        reset();
+        dispatch(fetchPnds())
+        dispatch(fetchUser())
+    }
 
 
     const onSubmit: SubmitHandler<FormData> = async (data) => {
@@ -57,7 +62,7 @@ export const useModalViewModel = ({ hide }: ModalViewModelType) => {
             }
         }
 
-        if (!user?.id) {
+        if (!user?.id || !user?.roles) {
             toast.error("global.error.missing_user")
             return
         }
@@ -65,7 +70,7 @@ export const useModalViewModel = ({ hide }: ModalViewModelType) => {
         const request: NetworkelementAddListRequest = {
             timestamp: Date.now().toString(),
             mne: [mne],
-            pid: user.id
+            pid: Object.keys(user.roles)[0]
         };
 
         try {
-- 
GitLab


From 7d3743f2e0c50252d3c1af17974491770c7413ae Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 11:34:16 +0100
Subject: [PATCH 2/8] (ui): disabled boxes when no device is selected

---
 .../components/devices/view/device.view.tsx   |  4 ++
 .../shared/components/box/gridBox.view.scss   |  8 ++-
 .../shared/components/box/gridBox.view.tsx    | 18 ++++---
 react-ui/src/shared/style/colors.scss         | 51 +++++++++----------
 4 files changed, 45 insertions(+), 36 deletions(-)

diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx
index 2523e4ae1..b4d6c843e 100755
--- a/react-ui/src/components/devices/view/device.view.tsx
+++ b/react-ui/src/components/devices/view/device.view.tsx
@@ -5,6 +5,7 @@ import {
   faSliders,
 } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAppSelector } from "@hooks";
 import { GridLayout } from "@layout/grid.layout/grid.layout";
 import { GridBox } from "@shared/components/box/gridBox.view";
 import { JsonViewer } from "@shared/components/json_viewer/view/json_viewer.view";
@@ -21,6 +22,7 @@ const DeviceView = () => {
   const { t } = useTranslation("common");
   const searchRef = useRef<HTMLInputElement>(null);
   const { jsonYang, openAddModal, closeModal, addModal } = useDeviceViewModel();
+  const { selected } = useAppSelector((root) => root.device);
 
   return (
     <GridLayout>
@@ -62,6 +64,7 @@ const DeviceView = () => {
           <GridBox
             title={t("device.box.information.title")}
             title_icon={faCircleInfo}
+            disabled={!selected?.device}
           >
             <Row>
               <Col xs={12}>
@@ -77,6 +80,7 @@ const DeviceView = () => {
           <GridBox
             title={t("device.box.configuration.title")}
             title_icon={faSliders}
+            disabled={!selected?.device}
           >
             <Row>
               <Col xs={12}>{jsonYang && <JsonViewer json={jsonYang} />}</Col>
diff --git a/react-ui/src/shared/components/box/gridBox.view.scss b/react-ui/src/shared/components/box/gridBox.view.scss
index f247f654c..38d6f9f3b 100644
--- a/react-ui/src/shared/components/box/gridBox.view.scss
+++ b/react-ui/src/shared/components/box/gridBox.view.scss
@@ -25,7 +25,7 @@ $transition-duration: 0.3s;
     background-color: white;
     position: relative;
     transition: box-shadow $transition-duration ease-in-out;
-    @extend .border-gradient;
+
     @extend .rounded;
     box-shadow: $box-shadow;
 
@@ -36,6 +36,12 @@ $transition-duration: 0.3s;
             opacity: 1;
         }
     }
+
+    &.disabled {
+        box-shadow: 0 0.5rem 1rem rgba(map-get($theme-colors, "disabled"), 0.2);
+        @extend .border-gradient-disabled;
+    }
+    @extend .border-gradient-primary;
 }
 
 .c-box-title {
diff --git a/react-ui/src/shared/components/box/gridBox.view.tsx b/react-ui/src/shared/components/box/gridBox.view.tsx
index 40da8ada3..b0e35a157 100644
--- a/react-ui/src/shared/components/box/gridBox.view.tsx
+++ b/react-ui/src/shared/components/box/gridBox.view.tsx
@@ -12,6 +12,7 @@ interface GridBoxProps {
   title_icon: IconDefinition;
   children: React.ReactNode;
   className?: string;
+  disabled?: boolean;
 }
 
 export const GridBox: React.FC<GridBoxProps> = ({
@@ -19,26 +20,29 @@ export const GridBox: React.FC<GridBoxProps> = ({
   title,
   title_icon,
   className = "",
+  disabled = false,
 }) => {
   return (
     <div className="grid-box h-100">
       <Container
         fluid
-        className={`c-box d-flex flex-column h-100 ${className}`}
+        className={`c-box d-flex ${disabled && "text-disabled disabled"} flex-column h-100 ${className}`}
       >
         <div>
-          <UpdateIndicator
-            category={Category.DEVICE as CategoryType}
-            updateInterval={15000}
-          />
+          {!disabled && (
+            <UpdateIndicator
+              category={Category.DEVICE as CategoryType}
+              updateInterval={15000}
+            />
+          )}
           <FontAwesomeIcon icon={faGripVertical} className="drag-handle" />
           <Row className="mb-0">
             <Col xs={12}>
-              <h4 className="c-box-title">
+              <h4 className={`c-box-title ${disabled && "text-disabled"}`}>
                 <FontAwesomeIcon
                   icon={title_icon}
                   size="1x"
-                  className="me-2 text-primary"
+                  className={`me-2 ${disabled ? "text-disabled" : "text-primary"}`}
                 />
                 {title}
               </h4>
diff --git a/react-ui/src/shared/style/colors.scss b/react-ui/src/shared/style/colors.scss
index 9ba6ce0c0..1b567b7c6 100755
--- a/react-ui/src/shared/style/colors.scss
+++ b/react-ui/src/shared/style/colors.scss
@@ -16,35 +16,30 @@ $transition-duration: 0.3s;
 @import "/node_modules/bootstrap/scss/bootstrap";
 
 // Gradients
+$gradient-colors: (
+  "primary": map-get($theme-colors, "primary"),
+  "disabled": map-get($theme-colors, "disabled")
+);
 
-.border-gradient {
-  background:
-    linear-gradient(white, white) padding-box,
-    linear-gradient(
-        180deg,
-        rgba(map-get($theme-colors, "primary"), 0.4) 0%,
-        rgba(map-get($theme-colors, "primary"), 0.2) 40%,
-        rgba(map-get($theme-colors, "primary"), 0.1) 100%
-      )
-      border-box;
-  border: $border-width solid transparent;
+@each $name, $color in $gradient-colors {
+  .border-gradient-#{$name} {
+    background:
+      linear-gradient(white, white) padding-box,
+      linear-gradient(180deg, rgba($color, 0.4) 0%, rgba($color, 0.2) 40%, rgba($color, 0.1) 100%) border-box;
+    border: $border-width solid transparent;
 
-  &::before {
-    content: "";
-    position: absolute;
-    top: -$border-width;
-    left: -$border-width;
-    right: -$border-width;
-    bottom: -$border-width;
-    background: linear-gradient(
-      180deg,
-      rgba(map-get($theme-colors, "primary"), 0.4) 0%,
-      rgba(map-get($theme-colors, "primary"), 0.2) 60%,
-      rgba(map-get($theme-colors, "primary"), 0.1) 100%
-    );
-    border-radius: inherit;
-    z-index: -1;
-    opacity: 0;
-    transition: opacity $transition-duration ease-in-out;
+    &::before {
+      content: "";
+      position: absolute;
+      top: -$border-width;
+      left: -$border-width;
+      right: -$border-width;
+      bottom: -$border-width;
+      background: linear-gradient(180deg, rgba($color, 0.4) 0%, rgba($color, 0.2) 60%, rgba($color, 0.1) 100%);
+      border-radius: inherit;
+      z-index: -1;
+      opacity: 0;
+      transition: opacity $transition-duration ease-in-out;
+    }
   }
 }
-- 
GitLab


From efed8a3eede21cc7fe6683329bcb371a4a99bb34 Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 12:20:50 +0100
Subject: [PATCH 3/8] (ui): add device list scroll

---
 react-ui/.prettierrc                          | 10 +++--
 .../components/devices/view/device.view.tsx   | 37 ++-----------------
 2 files changed, 11 insertions(+), 36 deletions(-)

diff --git a/react-ui/.prettierrc b/react-ui/.prettierrc
index 7ed67eff4..3a9d4e8fc 100755
--- a/react-ui/.prettierrc
+++ b/react-ui/.prettierrc
@@ -1,7 +1,11 @@
 {
     "semi": false,
     "singleQuote": true,
-    "trailingComma": "es5",
+    "trailingComma": "all",
     "tabWidth": 4,
-    "printWidth": 80
-}
+    "printWidth": 100,
+    "bracketSpacing": true,
+    "arrowParens": "avoid",
+    "bracketSameLine": true,
+    "singleAttributePerLine": false
+}
\ No newline at end of file
diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx
index b4d6c843e..1328781f5 100755
--- a/react-ui/src/components/devices/view/device.view.tsx
+++ b/react-ui/src/components/devices/view/device.view.tsx
@@ -1,27 +1,25 @@
+// device.view.tsx
 import {
   faCircleInfo,
-  faPlus,
   faServer,
   faSliders,
 } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { useAppSelector } from "@hooks";
 import { GridLayout } from "@layout/grid.layout/grid.layout";
 import { GridBox } from "@shared/components/box/gridBox.view";
 import { JsonViewer } from "@shared/components/json_viewer/view/json_viewer.view";
 import { useRef } from "react";
-import { Button, Col, Form, Row } from "react-bootstrap";
+import { Col, Row } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 import { useDeviceViewModel } from "../view_model/device.viewmodel";
 import { DeviceList } from "./boxes/devices.box.view";
 import { DeviceListCollapsable } from "./boxes/information.box.view";
 import "./device.scss";
-import AddDeviceModal from "./subcomponent/modal.view";
 
 const DeviceView = () => {
   const { t } = useTranslation("common");
   const searchRef = useRef<HTMLInputElement>(null);
-  const { jsonYang, openAddModal, closeModal, addModal } = useDeviceViewModel();
+  const { jsonYang } = useDeviceViewModel();
   const { selected } = useAppSelector((root) => root.device);
 
   return (
@@ -29,34 +27,7 @@ const DeviceView = () => {
       <>
         <div key="device-list">
           <GridBox title={t("device.box.list.title")} title_icon={faServer}>
-            <Row className="mb-3 align-items-center">
-              <Col xs={12} md={6} lg={8}>
-                <Form.Group controlId="device.search">
-                  <Form.Control
-                    type="text"
-                    placeholder={t("device.search.placeholder")}
-                    ref={searchRef}
-                  />
-                </Form.Group>
-              </Col>
-              <Col xs={12} md={6} lg={4} className="mt-3 mt-md-0 text-md-end">
-                <Button
-                  variant="primary::button"
-                  className="btn-primary-button"
-                  onClick={() => openAddModal()}
-                >
-                  <FontAwesomeIcon icon={faPlus} className="me-2" />
-                  {t("device.add_device_button")}
-                </Button>
-
-                <AddDeviceModal show={addModal} onHide={() => closeModal()} />
-              </Col>
-            </Row>
-            <Row>
-              <Col xs={12} className="h-auto">
-                <DeviceList searchRef={searchRef} />
-              </Col>
-            </Row>
+            <DeviceList searchRef={searchRef} />
           </GridBox>
         </div>
 
-- 
GitLab


From 67e799f6e5405d73f4b8e7c745396f63d7e0cb26 Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 12:56:48 +0100
Subject: [PATCH 4/8] (ui): refactor information box

---
 .../devices/view/boxes/devices.box.view.tsx   | 141 ++++++++++++++++++
 .../view/boxes/information.box.view.tsx       | 112 ++++++++++++++
 .../view_model/device.box.viewmodel.ts        |  56 +++++++
 .../view_model/information.box.viewmodel.ts   | 104 +++++++++++++
 4 files changed, 413 insertions(+)
 create mode 100755 react-ui/src/components/devices/view/boxes/devices.box.view.tsx
 create mode 100644 react-ui/src/components/devices/view/boxes/information.box.view.tsx
 create mode 100644 react-ui/src/components/devices/view_model/device.box.viewmodel.ts
 create mode 100755 react-ui/src/components/devices/view_model/information.box.viewmodel.ts

diff --git a/react-ui/src/components/devices/view/boxes/devices.box.view.tsx b/react-ui/src/components/devices/view/boxes/devices.box.view.tsx
new file mode 100755
index 000000000..2003d423d
--- /dev/null
+++ b/react-ui/src/components/devices/view/boxes/devices.box.view.tsx
@@ -0,0 +1,141 @@
+import { useDeviceBoxViewModel } from '@component/devices/view_model/device.box.viewmodel'
+import { faPlus } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { insertMarkTags } from '@helper/text'
+import DOMPurify from 'dompurify'
+import { RefObject, useCallback } from 'react'
+import { Button, Col, Form, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { Device } from '../../reducer/device.reducer'
+import AddDeviceModal from '../subcomponent/modal.view'
+
+export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElement> }) => {
+    const { t } = useTranslation('common')
+    const {
+        filteredDevices,
+        handleItemClick,
+        selectedDevice,
+        pnds,
+        addModal,
+        openAddModal,
+        closeModal,
+        searchValue,
+        handleSearch,
+    } = useDeviceBoxViewModel(searchRef)
+
+    const cropUUID = (uuid: string): string => {
+        return uuid.substring(0, 3) + '...' + uuid.substring(uuid.length - 3, uuid.length)
+    }
+
+    const renderDeviceItem = useCallback(
+        (device: Device) => {
+            const user = pnds.find(pnd => pnd.id === device.pid)
+            const username = user?.name || ''
+            const deviceId = device.id!
+            const croppedId = cropUUID(deviceId)
+            const devicename = device.name || ''
+            const isSelected = selectedDevice?.device.id === deviceId
+
+            return (
+                <div
+                    key={deviceId}
+                    className={`border-bottom border-primary p-2 transitions ${
+                        isSelected && 'bg-gradient-fade py-2'
+                    } ${!isSelected && 'text-disabled disabled-hover'}`}
+                    onClick={() => handleItemClick(device)}>
+                    <Row className="align-items-center clickable">
+                        <Col xs={12} sm={5}>
+                            <span
+                                dangerouslySetInnerHTML={{
+                                    __html: searchValue
+                                        ? insertMarkTags(devicename, searchValue)
+                                        : DOMPurify.sanitize(devicename),
+                                }}
+                            />
+                        </Col>
+                        <Col xs={12} sm={3}>
+                            <OverlayTrigger overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>}>
+                                <span
+                                    className="text-gray-500"
+                                    dangerouslySetInnerHTML={{
+                                        __html: searchValue
+                                            ? insertMarkTags(croppedId, searchValue)
+                                            : DOMPurify.sanitize(croppedId),
+                                    }}
+                                />
+                            </OverlayTrigger>
+                        </Col>
+                        <Col xs={12} sm={4}>
+                            <span
+                                className="text-gray-500"
+                                dangerouslySetInnerHTML={{
+                                    __html: searchValue
+                                        ? insertMarkTags(username, searchValue)
+                                        : DOMPurify.sanitize(username),
+                                }}
+                            />
+                        </Col>
+                    </Row>
+                </div>
+            )
+        },
+        [selectedDevice, pnds, handleItemClick, searchValue],
+    )
+
+    return (
+        <div className="d-flex flex-column h-100">
+            {/* Fixed top section */}
+            <div className="flex-shrink-0">
+                <Row className="mb-3 align-items-center">
+                    <Col xs={12} md={6} lg={8}>
+                        <Form.Group controlId="device.search">
+                            <Form.Control
+                                type="text"
+                                placeholder={t('device.search.placeholder')}
+                                ref={searchRef}
+                                value={searchValue}
+                                onChange={e => handleSearch(e.target.value)}
+                            />
+                        </Form.Group>
+                    </Col>
+                    <Col xs={12} md={6} lg={4} className="mt-3 mt-md-0 text-md-end">
+                        <Button
+                            variant="primary::button"
+                            className="btn-primary-button"
+                            onClick={openAddModal}>
+                            <FontAwesomeIcon icon={faPlus} className="me-2" />
+                            {t('device.add_device_button')}
+                        </Button>
+
+                        <AddDeviceModal show={addModal} onHide={closeModal} />
+                    </Col>
+                </Row>
+            </div>
+
+            {/* Scrollable list section */}
+            <div className="flex-grow-1 overflow-y-auto overflow-x-hidden">
+                <div className="rounded border border-primary">
+                    {/* Fixed header */}
+                    <div className="sticky-top bg-white border-bottom border-primary">
+                        <Row className="px-2 py-2 mx-0">
+                            <Col xs={12} sm={5}>
+                                <span className="font-medium">{t('device.table.header.name')}</span>
+                            </Col>
+                            <Col xs={12} sm={3}>
+                                <span className="font-medium">{t('device.table.header.uuid')}</span>
+                            </Col>
+                            <Col xs={12} sm={4}>
+                                <span className="font-medium">{t('device.table.header.user')}</span>
+                            </Col>
+                        </Row>
+                    </div>
+
+                    {/* Scrollable content */}
+                    <div className="device-list-content">
+                        {filteredDevices.map(renderDeviceItem)}
+                    </div>
+                </div>
+            </div>
+        </div>
+    )
+}
diff --git a/react-ui/src/components/devices/view/boxes/information.box.view.tsx b/react-ui/src/components/devices/view/boxes/information.box.view.tsx
new file mode 100644
index 000000000..6df0e3333
--- /dev/null
+++ b/react-ui/src/components/devices/view/boxes/information.box.view.tsx
@@ -0,0 +1,112 @@
+import { faChevronDown, faHashtag, faUser } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { insertMarkTags } from '@helper/text'
+import { useAppSelector } from '@hooks'
+import { JsonViewer } from '@shared/components/json_viewer/view/json_viewer.view'
+import DOMPurify from 'dompurify'
+import { useCallback, useState } from 'react'
+import { Collapse } from 'react-bootstrap'
+
+interface DeviceListCollapsableProps {
+    search?: string
+}
+
+enum Collapsables {
+    Metadata = 1,
+    Config = 2,
+}
+
+export const DeviceListCollapsable = ({ search }: DeviceListCollapsableProps) => {
+    const { selected, pnds } = useAppSelector(state => state.device)
+
+    const [collapseable, setCollapsable] = useState<Collapsables | undefined>(undefined)
+
+    const user = pnds.find(pnd => pnd.id === selected?.device.pid)
+    const username = user?.name || ''
+    const deviceId = selected?.device.id || ''
+    const json = selected?.json || {}
+
+    const metadataKey = Object.keys(json).at(2) as keyof typeof json
+    const metadataObject = (json[metadataKey] as JSON) || {}
+
+    const configKey = Object.keys(json).at(0) as keyof typeof json
+    const configObject = (json[configKey] as JSON) || {}
+
+    const setCollapsed = useCallback((section: Collapsables) => {
+        setCollapsable(prev => (prev === section ? undefined : section))
+    }, [])
+
+    const renderDeviceInfo = useCallback(
+        () => (
+            <>
+                <div className="d-flex justify-content-between">
+                    <div>
+                        <FontAwesomeIcon className="me-2" icon={faHashtag} />
+                        UUID:
+                    </div>
+                    <span
+                        dangerouslySetInnerHTML={{
+                            __html: search
+                                ? insertMarkTags(deviceId, search)
+                                : DOMPurify.sanitize(deviceId),
+                        }}
+                    />
+                </div>
+                <div className="d-flex justify-content-between">
+                    <div>
+                        <FontAwesomeIcon className="me-2" icon={faUser} />
+                        User:
+                    </div>
+                    <span>{username}</span>
+                </div>
+            </>
+        ),
+        [deviceId, search, username],
+    )
+
+    const renderCollapsableSection = useCallback(
+        (title: string, section: Collapsables, content: JSON) => (
+            <>
+                <div
+                    className={`d-flex justify-content-between clickable ${
+                        section === Collapsables.Config
+                            ? 'mt-3'
+                            : 'border-top border-dark mt-3 pt-3'
+                    }`}
+                    aria-expanded={collapseable === section}
+                    onClick={() => setCollapsed(section)}>
+                    <div>
+                        <FontAwesomeIcon
+                            icon={faChevronDown}
+                            rotation={collapseable === section ? undefined : 270}
+                        />
+                        {title}
+                    </div>
+                </div>
+
+                <Collapse in={collapseable === section}>
+                    <div id={`collapse-${deviceId}-${section}`}>
+                        {JsonViewer({
+                            json: content,
+                            options: {
+                                editable: false,
+                                searchEnabled: false,
+                            },
+                        })}
+                    </div>
+                </Collapse>
+            </>
+        ),
+        [collapseable, deviceId, setCollapsed],
+    )
+
+    return (
+        <div id={`collapse-${deviceId}`}>
+            <div className="pb-4 pt-1 d-flex flex-column gap-1">
+                {renderDeviceInfo()}
+                {renderCollapsableSection('Metadata', Collapsables.Metadata, metadataObject)}
+                {renderCollapsableSection('Config', Collapsables.Config, configObject)}
+            </div>
+        </div>
+    )
+}
diff --git a/react-ui/src/components/devices/view_model/device.box.viewmodel.ts b/react-ui/src/components/devices/view_model/device.box.viewmodel.ts
new file mode 100644
index 000000000..39a4bd051
--- /dev/null
+++ b/react-ui/src/components/devices/view_model/device.box.viewmodel.ts
@@ -0,0 +1,56 @@
+// devices.box.viewmodel.ts
+import { useAppDispatch, useAppSelector } from "@hooks";
+import { RefObject, useCallback, useMemo, useState } from "react";
+import { Device, setSelectedDevice } from "../reducer/device.reducer";
+import { fetchPluginsThunk } from "../routines/plugin.routine";
+
+export const useDeviceBoxViewModel = (searchRef: RefObject<HTMLInputElement>) => {
+    const dispatch = useAppDispatch();
+    const { devices, pnds, selected: selectedDevice } = useAppSelector(
+        (state) => state.device
+    );
+    const [addModal, setAddModal] = useState(false);
+    const [searchValue, setSearchValue] = useState(""); // Add search state
+
+    const handleSearch = useCallback((value: string) => {
+        setSearchValue(value);
+    }, []);
+
+    const handleItemClick = useCallback((device: Device) => {
+        dispatch(setSelectedDevice({ device }));
+    }, []);
+
+    const openAddModal = useCallback(() => {
+        dispatch(fetchPluginsThunk());
+        setAddModal(true);
+    }, []);
+
+    const closeModal = useCallback(() => {
+        setAddModal(false);
+    }, []);
+
+    const filteredDevices = useMemo(() => {
+        if (!searchValue) return devices;
+
+        return devices.filter((device) => {
+            const user = pnds.find((pnd) => pnd.id === device.pid);
+            return (
+                device.id?.toLowerCase().includes(searchValue.toLowerCase()) ||
+                device.name?.toLowerCase().includes(searchValue.toLowerCase()) ||
+                user?.name?.toLowerCase().includes(searchValue.toLowerCase())
+            );
+        });
+    }, [devices, pnds, searchValue]); // Now depends on searchValue instead of ref
+
+    return {
+        filteredDevices,
+        handleItemClick,
+        selectedDevice,
+        pnds,
+        addModal,
+        openAddModal,
+        closeModal,
+        searchValue,
+        handleSearch,
+    };
+};
\ No newline at end of file
diff --git a/react-ui/src/components/devices/view_model/information.box.viewmodel.ts b/react-ui/src/components/devices/view_model/information.box.viewmodel.ts
new file mode 100755
index 000000000..546e72086
--- /dev/null
+++ b/react-ui/src/components/devices/view_model/information.box.viewmodel.ts
@@ -0,0 +1,104 @@
+import { Device, setSelectedDevice } from "@component/devices/reducer/device.reducer";
+import { faCopy } from "@fortawesome/free-solid-svg-icons";
+import { useAppDispatch } from "@hooks";
+import { useMenu } from "@provider/menu/menu.provider";
+import { useUtils } from "@provider/utils.provider";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "react-toastify";
+
+export const useInformationViewModel = (searchRef, listRef) => {
+    const [searchTerm, setSearchTerm] = useState('');
+    const dispatch = useAppDispatch();
+    const { subscribe } = useMenu();
+    const { toClipboard } = useUtils();
+    const { t } = useTranslation('common');
+
+
+    const registerMenuOptions = () => {
+        const subscription = subscribe!({
+            target: listRef.current,
+            actions: [
+                {
+                    key: t('device.table.actions.copy'),
+                    icon: faCopy,
+                    action: (clickedElement) => {
+                        if (clickedElement) {
+                            const text = clickedElement.dataset.copyValue
+                            if (!text) {
+                                toast.warn(t('global.toast.copied_failed'))
+                                return
+                            }
+
+
+                            toClipboard(text)
+                        }
+                    }
+                },
+
+                {
+                    key: t('device.table.actions.copy_row'),
+                    icon: faCopy,
+                    action: (clickedElement) => {
+                        let parent = clickedElement;
+                        while (parent && parent.tagName !== 'TR') {
+                            parent = parent.parentNode;
+                        }
+
+                        const text = parent.dataset.copyValue
+                        if (!text) {
+                            toast.warn(t('global.toast.copied_failed'))
+                            return
+                        }
+                        toClipboard(text)
+                    }
+                }
+            ]
+        })
+
+        return () => {
+            subscription.unsubscribe()
+        }
+    }
+
+    useEffect(() => {
+        if (!subscribe || !listRef?.current) {
+            return
+        }
+
+        const unsubscribe = registerMenuOptions()
+
+        return () => {
+            unsubscribe()
+        }
+    }, [listRef, subscribe])
+
+
+    useEffect(() => {
+        if (!searchRef?.current) {
+            return
+        }
+
+        const handleSearchChange = () => {
+            setSearchTerm(searchRef.current.value);
+        };
+
+        searchRef.current.addEventListener('input', handleSearchChange);
+
+        return () => {
+            if (searchRef.current) {
+                searchRef.current.removeEventListener('input', handleSearchChange);
+            }
+        };
+    }, [searchRef]);
+
+    const dispatchDevice = (device: Device) => {
+        dispatch(setSelectedDevice({ device }));
+    }
+
+
+    return {
+        searchTerm,
+        dispatchDevice
+    }
+}
\ No newline at end of file
-- 
GitLab


From 76b55c2cb6517a74f10c0f1ee9bbf2e3f8ea154d Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 14:42:23 +0100
Subject: [PATCH 5/8] (ui): bugfix: move translation text

---
 react-ui/src/i18n/locales/en/translations.json | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/react-ui/src/i18n/locales/en/translations.json b/react-ui/src/i18n/locales/en/translations.json
index 8a81a03e9..8c4b56dcd 100755
--- a/react-ui/src/i18n/locales/en/translations.json
+++ b/react-ui/src/i18n/locales/en/translations.json
@@ -12,9 +12,6 @@
             "menu_item": {
                 "logout": "Logout"
             },
-            "box": {
-                "lastUpdate": "Last updated {{seconds}} seconds ago"
-            },
             "error": {
                 "missing_user": "Error: User information. Please relogin and try it again"
             }
@@ -44,7 +41,8 @@
                 },
                 "configuration": {
                     "title": "Configuration"
-                }
+                },
+                "lastUpdate": "Last updated {{seconds}} seconds ago"
             },
             "add_device": {
                 "success": "Device successfully added",
-- 
GitLab


From 0378167781b0e6e3232aba2dccdb7132ae97da4a Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 15:03:07 +0100
Subject: [PATCH 6/8] (ui): add custom scrollbar

---
 .../src/components/devices/view/boxes/devices.box.view.tsx   | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/react-ui/src/components/devices/view/boxes/devices.box.view.tsx b/react-ui/src/components/devices/view/boxes/devices.box.view.tsx
index 2003d423d..0204f9707 100755
--- a/react-ui/src/components/devices/view/boxes/devices.box.view.tsx
+++ b/react-ui/src/components/devices/view/boxes/devices.box.view.tsx
@@ -2,6 +2,7 @@ import { useDeviceBoxViewModel } from '@component/devices/view_model/device.box.
 import { faPlus } from '@fortawesome/free-solid-svg-icons'
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 import { insertMarkTags } from '@helper/text'
+import { Scrollbar } from '@shared/components/scrollbar/Scrollbar.view'
 import DOMPurify from 'dompurify'
 import { RefObject, useCallback } from 'react'
 import { Button, Col, Form, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'
@@ -113,7 +114,7 @@ export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElemen
             </div>
 
             {/* Scrollable list section */}
-            <div className="flex-grow-1 overflow-y-auto overflow-x-hidden">
+            <Scrollbar className="flex-grow-1 overflow-y-auto overflow-x-hidden" scrollX={false}>
                 <div className="rounded border border-primary">
                     {/* Fixed header */}
                     <div className="sticky-top bg-white border-bottom border-primary">
@@ -135,7 +136,7 @@ export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElemen
                         {filteredDevices.map(renderDeviceItem)}
                     </div>
                 </div>
-            </div>
+            </Scrollbar>
         </div>
     )
 }
-- 
GitLab


From 9e5eea068069b234462c35ce90a2a40b3f5433ca Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 15:09:03 +0100
Subject: [PATCH 7/8] (ui): add scrollbar to jsonviewer

---
 .../shared/components/box/gridBox.view.tsx    |  97 ++++++------
 .../json_viewer/view/json_viewer.view.tsx     | 139 +++++++++++-------
 2 files changed, 130 insertions(+), 106 deletions(-)

diff --git a/react-ui/src/shared/components/box/gridBox.view.tsx b/react-ui/src/shared/components/box/gridBox.view.tsx
index b0e35a157..0e3b0bffe 100644
--- a/react-ui/src/shared/components/box/gridBox.view.tsx
+++ b/react-ui/src/shared/components/box/gridBox.view.tsx
@@ -1,56 +1,55 @@
-import {
-  faGripVertical,
-  IconDefinition,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import UpdateIndicator from "@layout/grid.layout/update-inidicator.layout/update-indicator.layout";
-import { Category, CategoryType } from "@shared/types/category.type";
-import { Col, Container, Row } from "react-bootstrap";
+import { faGripVertical, IconDefinition } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import UpdateIndicator from '@layout/grid.layout/update-inidicator.layout/update-indicator.layout'
+import { Category, CategoryType } from '@shared/types/category.type'
+import { Col, Container, Row } from 'react-bootstrap'
+import { Scrollbar } from '../scrollbar/Scrollbar.view'
 
 interface GridBoxProps {
-  title: string;
-  title_icon: IconDefinition;
-  children: React.ReactNode;
-  className?: string;
-  disabled?: boolean;
+    title: string
+    title_icon: IconDefinition
+    children: React.ReactNode
+    className?: string
+    disabled?: boolean
 }
 
 export const GridBox: React.FC<GridBoxProps> = ({
-  children,
-  title,
-  title_icon,
-  className = "",
-  disabled = false,
+    children,
+    title,
+    title_icon,
+    className = '',
+    disabled = false,
 }) => {
-  return (
-    <div className="grid-box h-100">
-      <Container
-        fluid
-        className={`c-box d-flex ${disabled && "text-disabled disabled"} flex-column h-100 ${className}`}
-      >
-        <div>
-          {!disabled && (
-            <UpdateIndicator
-              category={Category.DEVICE as CategoryType}
-              updateInterval={15000}
-            />
-          )}
-          <FontAwesomeIcon icon={faGripVertical} className="drag-handle" />
-          <Row className="mb-0">
-            <Col xs={12}>
-              <h4 className={`c-box-title ${disabled && "text-disabled"}`}>
-                <FontAwesomeIcon
-                  icon={title_icon}
-                  size="1x"
-                  className={`me-2 ${disabled ? "text-disabled" : "text-primary"}`}
-                />
-                {title}
-              </h4>
-            </Col>
-          </Row>
+    return (
+        <div className="grid-box h-100">
+            <Container
+                fluid
+                className={`c-box d-flex ${disabled && 'text-disabled disabled'} flex-column h-100 ${className}`}>
+                <div>
+                    {!disabled && (
+                        <UpdateIndicator
+                            category={Category.DEVICE as CategoryType}
+                            updateInterval={15000}
+                        />
+                    )}
+                    <FontAwesomeIcon icon={faGripVertical} className="drag-handle" />
+                    <Row className="mb-0">
+                        <Col xs={12}>
+                            <h4 className={`c-box-title ${disabled && 'text-disabled'}`}>
+                                <FontAwesomeIcon
+                                    icon={title_icon}
+                                    size="1x"
+                                    className={`me-2 ${disabled ? 'text-disabled' : 'text-primary'}`}
+                                />
+                                {title}
+                            </h4>
+                        </Col>
+                    </Row>
+                </div>
+                <Scrollbar scrollX={false} className="flex-grow-1 content">
+                    {children}
+                </Scrollbar>
+            </Container>
         </div>
-        <div className="flex-grow-1 content">{children}</div>
-      </Container>
-    </div>
-  );
-};
+    )
+}
diff --git a/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx b/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx
index b2f409c17..02a08978c 100755
--- a/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx
+++ b/react-ui/src/shared/components/json_viewer/view/json_viewer.view.tsx
@@ -1,62 +1,79 @@
-import { faAlignRight, faPenToSquare, faTrashCan } from "@fortawesome/free-solid-svg-icons"
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { insertMarkTags } from "@helper/text"
+import { faAlignRight, faPenToSquare, faTrashCan } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { insertMarkTags } from '@helper/text'
 import DOMPurify from 'dompurify'
-import React, { Suspense, useMemo, useRef } from "react"
-import { Form, Table } from "react-bootstrap"
-import { useTranslation } from "react-i18next"
+import React, { Suspense, useMemo, useRef } from 'react'
+import { Form, Table } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
 import Skeleton from 'react-loading-skeleton'
-import { useJsonViewer } from "../viewmodel/json_viewer.viewmodel"
+import { useJsonViewer } from '../viewmodel/json_viewer.viewmodel'
 import './json_viewer.scss'
 
-
 type JsonViewerProbs = {
-    json: JSON,
+    json: JSON
     options?: {
         searchEnabled?: boolean
         editable?: boolean
     }
 }
 
-export const JsonViewer = ({ json, options = { searchEnabled: true, editable: true } }: JsonViewerProbs) => {
-    const { t } = useTranslation('common');
-    const htmlContainer = useRef(null);
-    const search = useRef<HTMLInputElement>(null);
-
-    const { getSubset, isCollapsed, collapseable, collapse, parameterizedJson, searchTerm } = useJsonViewer({ json, search, container: htmlContainer });
-
-    const renderInner = (innerJson: JSON, nested: number = 0, parentKey: string = "", path: string = "/network-instance/0/"): JSX.Element => {
-        path += parentKey + (parentKey === "" ? "" : "/")
+export const JsonViewer = ({
+    json,
+    options = { searchEnabled: true, editable: true },
+}: JsonViewerProbs) => {
+    const { t } = useTranslation('common')
+    const htmlContainer = useRef(null)
+    const search = useRef<HTMLInputElement>(null)
+
+    const { getSubset, isCollapsed, collapseable, collapse, parameterizedJson, searchTerm } =
+        useJsonViewer({ json, search, container: htmlContainer })
+
+    const renderInner = (
+        innerJson: JSON,
+        nested: number = 0,
+        parentKey: string = '',
+        path: string = '/network-instance/0/',
+    ): JSX.Element => {
+        path += parentKey + (parentKey === '' ? '' : '/')
 
         if (Object.entries(innerJson).length === 0) {
-            return <tr><td><Skeleton count={3}></Skeleton></td></tr>
+            return (
+                <tr>
+                    <td>
+                        <Skeleton count={3}></Skeleton>
+                    </td>
+                </tr>
+            )
         }
 
         return Object.entries(innerJson).map(([key, child]): JSX.Element => {
-            let collapsed = isCollapsed(key, nested);
+            let collapsed = isCollapsed(key, nested)
 
             // display only keys and values that matches
-            if (searchTerm !== "") {
+            if (searchTerm !== '') {
                 const foundPaths = parameterizedJson.current.filter(_path => _path === path)
 
                 //collapsed = !collapsed ? !!foundPaths.length : collapsed
                 collapsed = !!foundPaths.length
             }
 
-            const isObject = child instanceof Object;
-            let readableValue: string = isObject ? '' : DOMPurify.sanitize(child);
+            const isObject = child instanceof Object
+            let readableValue: string = isObject ? '' : DOMPurify.sanitize(child)
 
-            if (searchTerm !== "" && readableValue.includes(searchTerm)) {
+            if (searchTerm !== '' && readableValue.includes(searchTerm)) {
                 readableValue = insertMarkTags(readableValue, searchTerm)
             }
 
-            const icon = isObject ?
-                <span className={collapsed ? 'fa-rotate-90' : ''}>&gt;</span> : <FontAwesomeIcon className="icon fa-rotate-180" icon={faAlignRight} size="xs" />
+            const icon = isObject ? (
+                <span className={collapsed ? 'fa-rotate-90' : ''}>&gt;</span>
+            ) : (
+                <FontAwesomeIcon className="icon fa-rotate-180" icon={faAlignRight} size="xs" />
+            )
 
             // determine the margin-left: n indent
-            let tabs = 0.0;
+            let tabs = 0.0
             for (let i = 0; i < nested; i++) {
-                tabs += 0.4;
+                tabs += 0.4
             }
 
             let concatenatedKey = key
@@ -69,30 +86,45 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr
 
             concatenatedKey = DOMPurify.sanitize(concatenatedKey)
 
-            if (searchTerm !== "" && concatenatedKey.includes(searchTerm)) {
+            if (searchTerm !== '' && concatenatedKey.includes(searchTerm)) {
                 concatenatedKey = insertMarkTags(concatenatedKey, searchTerm)
             }
 
             return (
                 <React.Fragment key={`${nested}-${key}`}>
                     <tr
-                        className={"list-item-td " + key + " " + nested + " " + (isObject ? 'object' : '')}
+                        className={
+                            'list-item-td ' + key + ' ' + nested + ' ' + (isObject ? 'object' : '')
+                        }
                         data-copy-value={readableValue}
-                        onClick={() => { isObject ? collapse(key, nested, child) : null }}
-                    >
-                        <td style={{ marginLeft: tabs + 'em' }} className={"d-flex align-items-center "}>{icon}<span>&ensp;<span dangerouslySetInnerHTML={{ __html: concatenatedKey }} /></span></td>
-                        <td className="text-element text-truncate" dangerouslySetInnerHTML={{ __html: readableValue }}></td>
-                        {options?.editable &&
+                        onClick={() => {
+                            isObject ? collapse(key, nested, child) : null
+                        }}>
+                        <td
+                            style={{ marginLeft: tabs + 'em' }}
+                            className={'d-flex align-items-center '}>
+                            {icon}
+                            <span>
+                                &ensp;
+                                <span dangerouslySetInnerHTML={{ __html: concatenatedKey }} />
+                            </span>
+                        </td>
+                        <td
+                            className="text-element text-truncate"
+                            dangerouslySetInnerHTML={{ __html: readableValue }}></td>
+                        {options?.editable && (
                             <td className="text-end">
                                 <div className="d-flex icons justify-content-end align-items-center">
                                     <FontAwesomeIcon icon={faPenToSquare} size="sm" />
                                     <FontAwesomeIcon icon={faTrashCan} size="sm" />
                                 </div>
                             </td>
-                        }
-                    </tr >
-                    {isObject && collapsed && renderInner(innerChild, nested + 1, concatenatedKey, path)}
-                </React.Fragment >
+                        )}
+                    </tr>
+                    {isObject &&
+                        collapsed &&
+                        renderInner(innerChild, nested + 1, concatenatedKey, path)}
+                </React.Fragment>
             )
         })
     }
@@ -100,31 +132,24 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr
     const renderJson = (json: JSON): JSX.Element => {
         return (
             <Table className="m-0 p-0 list-unstyled">
-                <tbody>
-                    {
-                        renderInner(json)
-                    }
-                </tbody>
-            </Table >
+                <tbody>{renderInner(json)}</tbody>
+            </Table>
         )
     }
 
-
     const Hierarchy = useMemo(() => {
-        const subset = getSubset(json);
-        return (
-            <Suspense>
-                {renderJson(subset)}
-            </Suspense>
-        )
+        const subset = getSubset(json)
+        return <Suspense>{renderJson(subset)}</Suspense>
     }, [json, collapseable, searchTerm])
 
-
-
     const Search = (): React.ReactElement => {
         return (
-            <Form.Group controlId='json_viewer.search' className='p-0 '>
-                <Form.Control type="text" placeholder={t('device.search.placeholder')} ref={search} />
+            <Form.Group controlId="json_viewer.search" className="p-0 ">
+                <Form.Control
+                    type="text"
+                    placeholder={t('device.search.placeholder')}
+                    ref={search}
+                />
             </Form.Group>
         )
     }
@@ -135,4 +160,4 @@ export const JsonViewer = ({ json, options = { searchEnabled: true, editable: tr
             {Hierarchy}
         </div>
     )
-}
\ No newline at end of file
+}
-- 
GitLab


From 19c8bf6da7fac40c54ffe7de77a228e71ad1c240 Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Tue, 11 Feb 2025 15:20:56 +0100
Subject: [PATCH 8/8] (ui): add missing scrollbar

---
 .../components/scrollbar/Scrollbar.view.tsx   | 25 ++++++++++
 .../components/scrollbar/scrollbar.scss       | 50 +++++++++++++++++++
 2 files changed, 75 insertions(+)
 create mode 100644 react-ui/src/shared/components/scrollbar/Scrollbar.view.tsx
 create mode 100644 react-ui/src/shared/components/scrollbar/scrollbar.scss

diff --git a/react-ui/src/shared/components/scrollbar/Scrollbar.view.tsx b/react-ui/src/shared/components/scrollbar/Scrollbar.view.tsx
new file mode 100644
index 000000000..16191c32f
--- /dev/null
+++ b/react-ui/src/shared/components/scrollbar/Scrollbar.view.tsx
@@ -0,0 +1,25 @@
+import './scrollbar.scss'
+
+export const Scrollbar = ({ children, className = '', scrollX = true, scrollY = true }) => {
+    // Determine overflow classes based on scroll options
+    const getOverflowClass = () => {
+        if (scrollX && scrollY) return 'overflow-auto'
+        if (scrollX) return 'overflow-x-auto overflow-y-hidden'
+        if (scrollY) return 'overflow-y-auto overflow-x-hidden'
+        return 'overflow-hidden'
+    }
+
+    // Determine scroll direction classes
+    const getScrollDirectionClass = () => {
+        const classes = ['scrollable-content']
+        if (scrollX) classes.push('scroll-x')
+        if (scrollY) classes.push('scroll-y')
+        return classes.join(' ')
+    }
+
+    return (
+        <div className={`custom-scrollbar position-relative h-100 overflow-hidden ${className}`}>
+            <div className={`${getScrollDirectionClass()} ${getOverflowClass()}`}>{children}</div>
+        </div>
+    )
+}
diff --git a/react-ui/src/shared/components/scrollbar/scrollbar.scss b/react-ui/src/shared/components/scrollbar/scrollbar.scss
new file mode 100644
index 000000000..5fb481933
--- /dev/null
+++ b/react-ui/src/shared/components/scrollbar/scrollbar.scss
@@ -0,0 +1,50 @@
+@import "/src/shared/style/colors.scss";
+
+// Import or reference to your theme variables
+$scrollbar-width: 6px;
+$scrollbar-track-bg: rgba(grey, 0.1);
+$scrollbar-thumb-bg: map-get($theme-colors, "black");
+$scrollbar-thumb-hover-bg: map-get($theme-colors, "primary-hover");
+
+.custom-scrollbar {
+    --scrollbar-width: #{$scrollbar-width};
+    --scrollbar-track-bg: #{$scrollbar-track-bg};
+    --scrollbar-thumb-bg: #{$scrollbar-thumb-bg};
+    --scrollbar-thumb-hover-bg: #{$scrollbar-thumb-hover-bg};
+
+    .scrollable-content {
+        height: 100%;
+        width: 100%;
+
+        // Padding and margin for scrollbars
+        &.scroll-y {
+            padding-right: calc(#{$scrollbar-width} + 4px);
+            margin-right: calc(#{$scrollbar-width} * -1);
+        }
+
+        &.scroll-x {
+            padding-bottom: calc(#{$scrollbar-width} + 4px);
+            margin-bottom: calc(#{$scrollbar-width} * -1);
+        }
+
+        // Webkit scrollbar styles
+        &::-webkit-scrollbar {
+            width: $scrollbar-width;
+            height: $scrollbar-width;
+        }
+
+        &::-webkit-scrollbar-track {
+            background: $scrollbar-track-bg;
+        }
+
+        &::-webkit-scrollbar-thumb {
+            background-color: $scrollbar-thumb-hover-bg;
+            border-radius: calc(#{$scrollbar-width} / 2);
+            transition: background-color 0.2s ease-in-out;
+        }
+
+        // Firefox scrollbar styles
+        scrollbar-width: thin;
+        scrollbar-color: $scrollbar-thumb-hover-bg $scrollbar-track-bg;
+    }
+}
-- 
GitLab