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' : ''}>></span> : <FontAwesomeIcon className="icon fa-rotate-180" icon={faAlignRight} size="xs" /> + const icon = isObject ? ( + <span className={collapsed ? 'fa-rotate-90' : ''}>></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> <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> +   + <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