Skip to content
Snippets Groups Projects
Commit 4585c77d authored by Matthias Feyll's avatar Matthias Feyll
Browse files

(ui): add copy and copy row menu options

parent eed9fbb5
No related branches found
No related tags found
4 merge requests!1196[renovate] Update module golang.org/x/net to v0.32.0,!1195UI: implement add device functionality,!1167Ui refactor style,!1161Ui refactor style
Pipeline #247807 passed
...@@ -12,9 +12,11 @@ ...@@ -12,9 +12,11 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@fullhuman/postcss-purgecss": "^7.0.2",
"@reduxjs/toolkit": "^2.2.4", "@reduxjs/toolkit": "^2.2.4",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"crypto-js": "^4.2.0",
"dompurify": "^3.2.3", "dompurify": "^3.2.3",
"i18next": "^24.0.5", "i18next": "^24.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
...@@ -31,7 +33,6 @@ ...@@ -31,7 +33,6 @@
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"sass": "1.82.0", "sass": "1.82.0",
"sass-embedded": "^1.80.6", "sass-embedded": "^1.80.6",
"@fullhuman/postcss-purgecss": "^7.0.2",
"vite": "^6.0.3" "vite": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
...@@ -88,4 +89,4 @@ ...@@ -88,4 +89,4 @@
"last 1 safari version" "last 1 safari version"
] ]
} }
} }
\ No newline at end of file
import { insertMarkTags } from "@helper/text"; import { insertMarkTags } from "@helper/text";
import { useAppSelector } from "@hooks"; import { useAppSelector } from "@hooks";
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { MutableRefObject, useCallback } from "react"; import { MutableRefObject, useCallback, useRef } from "react";
import { OverlayTrigger, Table, Tooltip } from "react-bootstrap"; import { OverlayTrigger, Table, Tooltip } from "react-bootstrap";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDeviceTableViewModel } from "../view_model/device.table.viewmodel"; 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>) => { export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) => {
const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device); const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device);
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { trClickHandler } = useDeviceTableViewModel(searchRef); const tableRef = useRef();
const { trClickHandler } = useDeviceTableViewModel(searchRef, tableRef);
const cropUUID = (uuid: string): string => {
return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length);
}
const getDeviceTable = useCallback(() => { const getDeviceTable = useCallback(() => {
const search = searchRef.current?.value; const search = searchRef.current?.value;
...@@ -34,23 +34,28 @@ export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) = ...@@ -34,23 +34,28 @@ export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) =
return filtered.map((device, index) => { return filtered.map((device, index) => {
const user = pnds.find(pnd => pnd.id === device.pid); 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 ( return (
<tr key={index} onClick={() => trClickHandler(device)} className={selectedDevice?.device.id === device.id ? 'active' : ''}> <tr data-copy-value={rowData} key={index} onClick={() => trClickHandler(device)} className={selectedDevice?.device.id === deviceId ? 'active' : ''}>
<td key={0} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(device.name!, search) : DOMPurify.sanitize(device.name) }}></td> <td data-copy-value={devicename} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(devicename, search) : DOMPurify.sanitize(devicename) }}></td>
<OverlayTrigger overlay={<Tooltip id={device.id}>{device.id}</Tooltip>}> <OverlayTrigger overlay={<Tooltip id={device.id}>{deviceId}</Tooltip>}>
<td dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(cropUUID(device.id!), search) : DOMPurify.sanitize(cropUUID(device.id!)) }}></td> <td data-copy-value={deviceId} dangerouslySetInnerHTML={{ __html: search ? insertMarkTags(cropedId, search) : DOMPurify.sanitize(cropedId) }}></td>
</OverlayTrigger> </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> <td></td>
</tr> </tr>
) )
}) })
}, [devices, searchRef, pnds, selectedDevice, trClickHandler]); }, [devices, searchRef, pnds, selectedDevice, trClickHandler]);
return ( return (
<Table striped responsive className="device-table"> <Table striped responsive className="device-table" ref={tableRef}>
<thead> <thead>
<tr> <tr>
<th>{t('device.table.header.name')}</th> <th>{t('device.table.header.name')}</th>
......
import { Device, setSelectedDevice } from "@component/devices/reducer/device.reducer"; import { Device, setSelectedDevice } from "@component/devices/reducer/device.reducer";
import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { useAppDispatch } from "@hooks"; import { useAppDispatch } from "@hooks";
import { useMenu } from "@provider/menu/menu.provider";
import { useUtils } from "@provider/utils.provider";
import { useEffect, useState } from "react"; 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 [searchTerm, setSearchTerm] = useState('');
const dispatch = useAppDispatch(); 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(() => { useEffect(() => {
if (!searchRef.current) {
return
}
const handleSearchChange = () => { 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 () => { return () => {
if (searchRef.current) { if (searchRef.current) {
searchRef.current.removeEventListener('input', handleSearchChange); searchRef.current.removeEventListener('input', handleSearchChange);
} }
}; };
}, []); }, [searchRef]);
const trClickHandler = (device: Device) => { const trClickHandler = (device: Device) => {
dispatch(setSelectedDevice({ device })); dispatch(setSelectedDevice({ device }));
......
...@@ -6,7 +6,8 @@ ...@@ -6,7 +6,8 @@
"empty_field": "This field can´t be empty" "empty_field": "This field can´t be empty"
}, },
"toast": { "toast": {
"copied": "Copied to clipboard" "copied": "Copied to clipboard",
"copied_failed": "Copying to clipboard failed"
}, },
"menu_item": { "menu_item": {
"logout": "Logout" "logout": "Logout"
...@@ -35,6 +36,10 @@ ...@@ -35,6 +36,10 @@
"uuid": "UUID", "uuid": "UUID",
"user": "User", "user": "User",
"last_updated": "Last updated" "last_updated": "Last updated"
},
"actions": {
"copy": "Copy",
"copy_row": "Copy row"
} }
}, },
"search": { "search": {
......
...@@ -67,29 +67,31 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy ...@@ -67,29 +67,31 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy
} }
const registerMenuOptions = () => { const registerMenuOptions = () => {
if (container.current) { if (!container.current) {
const subscription = subscribe({ return () => { }
target: container.current, }
actions: [
{ const subscription = subscribe({
key: t('json_viewer.copy'), target: container.current,
icon: faCopy, actions: [
action: (clickedElement) => { {
let parent = clickedElement; key: t('json_viewer.copy'),
while (parent && parent.tagName !== 'TR') { icon: faCopy,
parent = parent.parentNode; action: (clickedElement) => {
} let parent = clickedElement;
while (parent && parent.tagName !== 'TR') {
const copyValue = parent.dataset.copyValue parent = parent.parentNode;
toClipboard(copyValue)
} }
const copyValue = parent.dataset.copyValue
toClipboard(copyValue)
} }
] }
}) ]
})
return () => { return () => {
subscription.unsubscribe(); subscription.unsubscribe();
}
} }
} }
...@@ -137,7 +139,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy ...@@ -137,7 +139,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy
}, [searchTerm]) }, [searchTerm])
useEffect(() => { useEffect(() => {
registerMenuOptions(); const unsubscribe = registerMenuOptions();
if (search.current) { if (search.current) {
search.current.addEventListener('input', handleSearchInput) search.current.addEventListener('input', handleSearchInput)
...@@ -147,6 +149,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy ...@@ -147,6 +149,7 @@ export const useJsonViewer = ({ json, search, container }: JsonViewerViewModelTy
if (search.current) { if (search.current) {
search.current.removeEventListener('input', handleSearchInput) search.current.removeEventListener('input', handleSearchInput)
} }
unsubscribe()
} }
}, []) }, [])
......
...@@ -19,13 +19,11 @@ type Action = { ...@@ -19,13 +19,11 @@ type Action = {
} }
interface MenuProviderType { interface MenuProviderType {
subscribe: (value: SubscriptionValue) => MenuSubscription subscribe: ((value: SubscriptionValue) => MenuSubscription) | null;
} }
const MenuContext = createContext<MenuProviderType>({ const MenuContext = createContext<MenuProviderType>({
subscribe: function (): MenuSubscription { subscribe: null
throw new Error("Function not implemented.");
}
}) })
interface SubscriptionValue { interface SubscriptionValue {
...@@ -33,11 +31,16 @@ interface SubscriptionValue { ...@@ -33,11 +31,16 @@ interface SubscriptionValue {
actions: Array<Action> actions: Array<Action>
} }
interface SubscriptionMap {
[id: string]: SubscriptionValue
}
export const MenuProvider: React.FC<BasicProp> = ({ children }) => { export const MenuProvider: React.FC<BasicProp> = ({ children }) => {
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [subscribedTargets, setSubscribedTargets] = useState<Array<SubscriptionValue>>([]) const [subscribedTargets, setSubscribedTargets] = useState<SubscriptionMap>({});
const { logout } = useAuth() const { logout } = useAuth()
const { t } = useTranslation('common') const { t } = useTranslation('common')
...@@ -61,12 +64,15 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => { ...@@ -61,12 +64,15 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => {
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => { const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault(); 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 }); setMenuPosition({ top: event.pageY, left: event.pageX });
setMenuItems(targets) setMenuItems(targets);
setClickedHtmlElement(event.target as HTMLElement) setClickedHtmlElement(event.target as HTMLElement);
displayMenu() displayMenu();
}; };
const displayMenu = () => { const displayMenu = () => {
...@@ -90,20 +96,27 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => { ...@@ -90,20 +96,27 @@ export const MenuProvider: React.FC<BasicProp> = ({ children }) => {
const value = useMemo<MenuProviderType>(() => { const value = useMemo<MenuProviderType>(() => {
return { return {
subscribe(target) { subscribe(target: SubscriptionValue) {
const index = subscribedTargets.length; const subscriptionId = crypto.randomUUID(); // Generate unique ID
setSubscribedTargets([...subscribedTargets, target]) setSubscribedTargets(prev => ({
...prev,
[subscriptionId]: target
}));
const subscription: MenuSubscription = { const subscription: MenuSubscription = {
unsubscribe() { unsubscribe() {
setSubscribedTargets([...subscribedTargets.splice(index, 1)]) setSubscribedTargets(prev => {
const next = { ...prev };
delete next[subscriptionId];
return next;
});
}, },
} }
return subscription return subscription;
}, },
} as MenuProviderType } as MenuProviderType
}, []) }, []);
return ( return (
<MenuContext.Provider value={value}> <MenuContext.Provider value={value}>
......
...@@ -4073,6 +4073,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5: ...@@ -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" shebang-command "^2.0.0"
which "^2.0.1" 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: crypto-random-string@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment