diff --git a/react-ui/package.json b/react-ui/package.json index 5767007a12e35553bf091fd00d8ec09411d7643a..30db7c97c2b869d29f09757dd1e7b558a59e645e 100755 --- a/react-ui/package.json +++ b/react-ui/package.json @@ -12,9 +12,11 @@ "@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", + "@fullhuman/postcss-purgecss": "^7.0.2", "@reduxjs/toolkit": "^2.2.4", "@vitejs/plugin-react": "^4.2.1", "bootstrap": "^5.3.3", + "crypto-js": "^4.2.0", "dompurify": "^3.2.3", "i18next": "^24.0.5", "jwt-decode": "^4.0.0", @@ -31,7 +33,6 @@ "redux-persist": "^6.0.0", "sass": "1.82.0", "sass-embedded": "^1.80.6", - "@fullhuman/postcss-purgecss": "^7.0.2", "vite": "^6.0.3" }, "devDependencies": { @@ -88,4 +89,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/react-ui/src/components/devices/view/device.view.table.tsx b/react-ui/src/components/devices/view/device.view.table.tsx index 337148f92837e4ebd627a04cd059016b314f3794..56ad05f3f66a1016442371663dbffea6ccab69df 100755 --- a/react-ui/src/components/devices/view/device.view.table.tsx +++ b/react-ui/src/components/devices/view/device.view.table.tsx @@ -1,20 +1,20 @@ import { insertMarkTags } from "@helper/text"; import { useAppSelector } from "@hooks"; import DOMPurify from 'dompurify'; -import { MutableRefObject, useCallback } from "react"; +import { MutableRefObject, useCallback, useRef } from "react"; import { OverlayTrigger, Table, Tooltip } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { useDeviceTableViewModel } from "../view_model/device.table.viewmodel"; +const cropUUID = (uuid: string): string => { + return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); +} + export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) => { const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device); const { t } = useTranslation('common'); - const { trClickHandler } = useDeviceTableViewModel(searchRef); - - - const cropUUID = (uuid: string): string => { - return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); - } + const tableRef = useRef(); + const { trClickHandler } = useDeviceTableViewModel(searchRef, tableRef); const getDeviceTable = useCallback(() => { const search = searchRef.current?.value; @@ -34,23 +34,28 @@ export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) = return filtered.map((device, index) => { const user = pnds.find(pnd => pnd.id === device.pid); + const username = user?.name || '' + const deviceId = device.id!; + const cropedId = cropUUID(deviceId) + const devicename = device.name || ''; + + const rowData = username + ";" + deviceId + ";" + devicename + return ( - <tr key={index} onClick={() => trClickHandler(device)} className={selectedDevice?.device.id === device.id ? 'active' : ''}> - <td key={0} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(device.name!, search) : DOMPurify.sanitize(device.name) }}></td> - <OverlayTrigger overlay={<Tooltip id={device.id}>{device.id}</Tooltip>}> - <td dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(cropUUID(device.id!), search) : DOMPurify.sanitize(cropUUID(device.id!)) }}></td> + <tr data-copy-value={rowData} key={index} onClick={() => trClickHandler(device)} className={selectedDevice?.device.id === deviceId ? 'active' : ''}> + <td data-copy-value={devicename} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(devicename, search) : DOMPurify.sanitize(devicename) }}></td> + <OverlayTrigger overlay={<Tooltip id={device.id}>{deviceId}</Tooltip>}> + <td data-copy-value={deviceId} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(cropedId, search) : DOMPurify.sanitize(cropedId) }}></td> </OverlayTrigger> - <td key={1} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(user?.name || '', search) : DOMPurify.sanitize(user?.name || '') }}></td> + <td data-copy-value={username} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(username, search) : DOMPurify.sanitize(username) }}></td> <td></td> </tr> ) }) }, [devices, searchRef, pnds, selectedDevice, trClickHandler]); - - return ( - <Table striped responsive className="device-table"> + <Table striped responsive className="device-table" ref={tableRef}> <thead> <tr> <th>{t('device.table.header.name')}</th> diff --git a/react-ui/src/components/devices/view_model/device.table.viewmodel.ts b/react-ui/src/components/devices/view_model/device.table.viewmodel.ts index 5769780ffd9299d1ff0bc2ec0bf4edbae421c7cf..4b328d63955f3827a3f6cdabbf83951505fe7dfa 100755 --- a/react-ui/src/components/devices/view_model/device.table.viewmodel.ts +++ b/react-ui/src/components/devices/view_model/device.table.viewmodel.ts @@ -1,29 +1,97 @@ 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) => { +export const useDeviceTableViewModel = (searchRef, tableRef) => { const [searchTerm, setSearchTerm] = useState(''); const dispatch = useAppDispatch(); + const { subscribe } = useMenu(); + const { toClipboard } = useUtils(); + const { t } = useTranslation('common'); + + + const registerMenuOptions = () => { + const subscription = subscribe({ + target: tableRef.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() + } + } + + // seperate use effect to rerun this after tableref and subscribe are initialized + useEffect(() => { + if (!subscribe || !tableRef.current) { + return + } + + const unsubscribe = registerMenuOptions() + + return () => { + unsubscribe() + } + }, [tableRef, subscribe]) useEffect(() => { + if (!searchRef.current) { + return + } + const handleSearchChange = () => { - if (searchRef.current) { - setSearchTerm(searchRef.current.value); - } + setSearchTerm(searchRef.current.value); }; - if (searchRef.current) { - searchRef.current.addEventListener('input', handleSearchChange); - } + searchRef.current.addEventListener('input', handleSearchChange); return () => { if (searchRef.current) { searchRef.current.removeEventListener('input', handleSearchChange); } }; - }, []); + }, [searchRef]); const trClickHandler = (device: Device) => { dispatch(setSelectedDevice({ device })); diff --git a/react-ui/src/i18n/locales/en/translations.json b/react-ui/src/i18n/locales/en/translations.json index 46b76563c7d78355e9484e49d344f4132bf3766d..fb3ca729c6ecc380475dd3c5c567befcc5508668 100755 --- a/react-ui/src/i18n/locales/en/translations.json +++ b/react-ui/src/i18n/locales/en/translations.json @@ -6,7 +6,8 @@ "empty_field": "This field can“t be empty" }, "toast": { - "copied": "Copied to clipboard" + "copied": "Copied to clipboard", + "copied_failed": "Copying to clipboard failed" }, "menu_item": { "logout": "Logout" @@ -35,6 +36,10 @@ "uuid": "UUID", "user": "User", "last_updated": "Last updated" + }, + "actions": { + "copy": "Copy", + "copy_row": "Copy row" } }, "search": { diff --git a/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx b/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx index 6c3ac78f306d0ff0b1c09263a4d791795757b51c..f0bc922126f6752e9fcb0182391bd3deea461bb7 100644 --- a/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx +++ b/react-ui/src/shared/components/json_viewer/viewmodel/json_viewer.viewmodel.tsx @@ -67,29 +67,31 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy } const registerMenuOptions = () => { - if (container.current) { - const subscription = subscribe({ - target: container.current, - actions: [ - { - key: t('json_viewer.copy'), - icon: faCopy, - action: (clickedElement) => { - let parent = clickedElement; - while (parent && parent.tagName !== 'TR') { - parent = parent.parentNode; - } - - const copyValue = parent.dataset.copyValue - toClipboard(copyValue) + if (!container.current) { + return () => { } + } + + const subscription = subscribe({ + target: container.current, + actions: [ + { + key: t('json_viewer.copy'), + icon: faCopy, + action: (clickedElement) => { + let parent = clickedElement; + while (parent && parent.tagName !== 'TR') { + parent = parent.parentNode; } + + const copyValue = parent.dataset.copyValue + toClipboard(copyValue) } - ] - }) + } + ] + }) - return () => { - subscription.unsubscribe(); - } + return () => { + subscription.unsubscribe(); } } @@ -137,7 +139,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy }, [searchTerm]) useEffect(() => { - registerMenuOptions(); + const unsubscribe = registerMenuOptions(); if (search.current) { search.current.addEventListener('input', handleSearchInput) @@ -147,6 +149,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy if (search.current) { search.current.removeEventListener('input', handleSearchInput) } + unsubscribe() } }, []) diff --git a/react-ui/src/shared/provider/menu/menu.provider.tsx b/react-ui/src/shared/provider/menu/menu.provider.tsx index b2525692b9f463312158d6443a17320e2bed19e7..f3af8e5c13573e8c317eb27e433ebf63872e093f 100644 --- a/react-ui/src/shared/provider/menu/menu.provider.tsx +++ b/react-ui/src/shared/provider/menu/menu.provider.tsx @@ -19,13 +19,11 @@ type Action = { } interface MenuProviderType { - subscribe: (value: SubscriptionValue) => MenuSubscription + subscribe: ((value: SubscriptionValue) => MenuSubscription) | null; } const MenuContext = createContext<MenuProviderType>({ - subscribe: function (): MenuSubscription { - throw new Error("Function not implemented."); - } + subscribe: null }) interface SubscriptionValue { @@ -33,11 +31,16 @@ interface SubscriptionValue { actions: Array<Action> } +interface SubscriptionMap { + [id: string]: SubscriptionValue +} + export const MenuProvider: React.FC<BasicProp> = ({ children }) => { const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [showMenu, setShowMenu] = useState(false); - const [subscribedTargets, setSubscribedTargets] = useState<Array<SubscriptionValue>>([]) + const [subscribedTargets, setSubscribedTargets] = useState<SubscriptionMap>({}); + const { logout } = useAuth() const { t } = useTranslation('common') @@ -61,12 +64,15 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => { const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => { event.preventDefault(); - const targets = subscribedTargets.filter(({ target }) => target.contains(event.target as HTMLElement)) + + const targets = Object.values(subscribedTargets).filter( + ({ target }) => target.contains(event.target as HTMLElement) + ); setMenuPosition({ top: event.pageY, left: event.pageX }); - setMenuItems(targets) - setClickedHtmlElement(event.target as HTMLElement) - displayMenu() + setMenuItems(targets); + setClickedHtmlElement(event.target as HTMLElement); + displayMenu(); }; const displayMenu = () => { @@ -90,20 +96,27 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => { const value = useMemo<MenuProviderType>(() => { return { - subscribe(target) { - const index = subscribedTargets.length; + subscribe(target: SubscriptionValue) { + const subscriptionId = crypto.randomUUID(); // Generate unique ID - setSubscribedTargets([...subscribedTargets, target]) + setSubscribedTargets(prev => ({ + ...prev, + [subscriptionId]: target + })); const subscription: MenuSubscription = { unsubscribe() { - setSubscribedTargets([...subscribedTargets.splice(index, 1)]) + setSubscribedTargets(prev => { + const next = { ...prev }; + delete next[subscriptionId]; + return next; + }); }, } - return subscription + return subscription; }, } as MenuProviderType - }, []) + }, []); return ( <MenuContext.Provider value={value}> diff --git a/react-ui/yarn.lock b/react-ui/yarn.lock index 62e8d8fa11691d208d00262c2a3faae13d4170ec..ec57231a8b42fbbbc2c25e6801504874e8cba618 100755 --- a/react-ui/yarn.lock +++ b/react-ui/yarn.lock @@ -4073,6 +4073,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"