diff --git a/react-ui/src/i18n/locales/en/translations.json b/react-ui/src/i18n/locales/en/translations.json index b58b299b170e536241dfa985c85ab4dd57cc3470..c5e03754834f6639d99dd37c69aa41f7c85e30e4 100755 --- a/react-ui/src/i18n/locales/en/translations.json +++ b/react-ui/src/i18n/locales/en/translations.json @@ -4,8 +4,14 @@ "form": { "submit": "Submit", "empty_field": "This field canĀ“t be empty" + }, + "toast": { + "copied": "Copied to clipboard" } }, + "json_viewer": { + "copy": "Copy" + }, "login": { "form": { "failed": "The username or password is invalid", diff --git a/react-ui/src/index.tsx b/react-ui/src/index.tsx index 384f33d8243090bab50bcd569186c19b7550312b..2d157fd9b9eceb58123071736402641ec615e88c 100755 --- a/react-ui/src/index.tsx +++ b/react-ui/src/index.tsx @@ -1,3 +1,4 @@ +import { MenuProvider } from '@provider/menu/menu.provider' import i18next from 'i18next' import React from 'react' import ReactDOM from 'react-dom/client' @@ -25,8 +26,10 @@ ReactDOM.createRoot(document.getElementById("root")).render( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <I18nextProvider i18n={i18next}> - {installToastify()} - <RouterProvider router={router} /> + <MenuProvider> + {installToastify()} + <RouterProvider router={router} /> + </MenuProvider> </I18nextProvider> </PersistGate> </Provider> diff --git a/react-ui/src/shared/components/json_viewer/view/json_viewer.scss b/react-ui/src/shared/components/json_viewer/view/json_viewer.scss index 387290aafaa6d2e9b2575a54c77f5dda00dfc25f..649f57e398e7fd4ea1908d3b410ea52ed5cfda46 100755 --- a/react-ui/src/shared/components/json_viewer/view/json_viewer.scss +++ b/react-ui/src/shared/components/json_viewer/view/json_viewer.scss @@ -11,7 +11,7 @@ color: lighten(map-get($map: $theme-colors, $key: "black"), 20%) !important; background-color: white !important; border: 0; - padding: 0.1em 0 !important; + padding: 0.2em 0 !important; } & > td:nth-child(2) { @@ -21,6 +21,19 @@ &:hover > td { background-color: map-get($theme-colors, "primary::hover") !important; } + + &:hover .icons { + color: map-get($theme-colors, "black") !important; + opacity: 100%; + transition: gap 0.3s; + gap: 0.7em; + } + + & > .text-end { + vertical-align: middle; + padding-top: 0 !important; + padding-right: 5px !important; + } } .list-item-td.object { @@ -28,10 +41,6 @@ color: map-get($map: $theme-colors, $key: "black") !important; } - &:not(:first-child) > td { - padding-top: 0.5em !important; - } - &:hover { cursor: pointer; } @@ -45,3 +54,11 @@ td .icon { font-size: 0.8em; } + +.icons { + color: lighten(map-get($map: $theme-colors, $key: "dark"), 20%); + gap: 0.5em; + opacity: 0%; + + min-width: 3em; +} 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 39bdb8738a1cf605c55bbdbed87ade432a6a037e..840784c0f346d591d2d3453d19036b37153a84c4 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,8 +1,10 @@ -import { faAlignRight } from "@fortawesome/free-solid-svg-icons" +import { faAlignRight, faCopy, faPenToSquare, faTrashCan } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { useAppDispatch } from "@hooks" -import React, { Suspense, useMemo } from "react" +import { useMenu } from "@provider/menu/menu.provider" +import { toClipboard } from "@utils/functions" +import React, { Suspense, useEffect, useMemo, useRef } from "react" import { Table } from "react-bootstrap" +import { useTranslation } from "react-i18next" import { useJsonViewer } from "../viewmodel/json_viewer.viewmodel" import './json_viewer.scss' @@ -12,7 +14,36 @@ type JsonViewerProbs = { export const JsonViewer = ({ json }: JsonViewerProbs) => { const { getSubset, breadcrumbs, isCollapsed, collapseable, collapse } = useJsonViewer(); - const dispatch = useAppDispatch(); + const { subscribe } = useMenu(); + const htmlContainer = useRef(null); + const { t } = useTranslation('common'); + + useEffect(() => { + if (htmlContainer.current) { + const subscription = subscribe({ + target: htmlContainer.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(); + } + } + }, []) const breadcrumbHTML = useMemo(() => { return ( @@ -46,10 +77,15 @@ export const JsonViewer = ({ json }: JsonViewerProbs) => { return ( <React.Fragment key={`${nested}-${key}`}> - <tr className={"list-item-td " + key + " " + nested + " " + (isObject ? 'object' : '')} onClick={() => { isObject ? collapse(key, nested, value) : null }} > + <tr className={"list-item-td " + key + " " + nested + " " + (isObject ? 'object' : '')} data-copy-value={readableValue} onClick={() => { isObject ? collapse(key, nested, value) : null }} > <td style={{ marginLeft: tabs + 'em' }} className={"d-flex align-items-center "}>{icon}<span> {key}</span></td> <td>{readableValue}</td> - <td className="text-end">comands</td> + <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(value, nested + 1) : ''} </React.Fragment > @@ -83,7 +119,7 @@ export const JsonViewer = ({ json }: JsonViewerProbs) => { }, [json, collapseable]) return ( - <div> + <div ref={htmlContainer}> {breadcrumbHTML} {hierarchyHTML} </div> diff --git a/react-ui/src/shared/provider/menu/menu.provider.scss b/react-ui/src/shared/provider/menu/menu.provider.scss new file mode 100644 index 0000000000000000000000000000000000000000..12393085a5b443d08e6cc5150811898b312d9c72 --- /dev/null +++ b/react-ui/src/shared/provider/menu/menu.provider.scss @@ -0,0 +1,11 @@ +.menu-container { + box-shadow: 0px 0px 5px gray; + + border-radius: 4px !important; +} + +.menu-button { + & > span { + margin-left: 10px; + } +} diff --git a/react-ui/src/shared/provider/menu/menu.provider.tsx b/react-ui/src/shared/provider/menu/menu.provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3d3aebc4ae8ba82748a2def770bb2d8c5ea652c0 --- /dev/null +++ b/react-ui/src/shared/provider/menu/menu.provider.tsx @@ -0,0 +1,124 @@ +import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; +import './menu.provider.scss'; + +interface MenuSubscription { + unsubscribe: () => void +} + +// describes a action decorated with a item name +// on click the action is getting executed +type Action = { + key: string, + icon: IconDefinition, + action: (clickedHtmlElement: HTMLElement | undefined) => void +} + +interface MenuProviderType { + subscribe: (value: SubscriptionValue) => MenuSubscription +} + +const MenuContext = createContext<MenuProviderType>({ + subscribe: function (value: SubscriptionValue): MenuSubscription { + throw new Error("Function not implemented."); + } +}) + +interface SubscriptionValue { + target: HTMLElement, + actions: Array<Action> +} + + +export const MenuProvider = ({ children }) => { + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [showMenu, setShowMenu] = useState(false); + const [subscribedTargets, setSubscribedTargets] = useState<Array<SubscriptionValue>>([]) + + const [menuItems, setMenuItems] = useState<Array<SubscriptionValue>>([]); + const [clickedHtmlElement, setClickedHtmlElement] = useState<HTMLElement>() + + const handleContextMenu = (event) => { + event.preventDefault(); + const targets = subscribedTargets.filter(({ target }) => target.contains(event.target)) + + setMenuPosition({ top: event.pageY, left: event.pageX }); + setMenuItems(targets) + setClickedHtmlElement(event.target) + displayMenu() + }; + + const displayMenu = () => { + setShowMenu(true); + } + + const hideMenu = () => { + setShowMenu(false); + setMenuItems([]); + } + + const handleClick = () => hideMenu(); + + useEffect(() => { + document.addEventListener('keyup', (e) => { + if (e.code === "Escape") { + hideMenu(); + } + }); + }, []) + + const value = useMemo<MenuProviderType>(() => { + return { + subscribe(target) { + const index = subscribedTargets.length; + + setSubscribedTargets([...subscribedTargets, target]) + + const subscription: MenuSubscription = { + unsubscribe() { + setSubscribedTargets([...subscribedTargets.splice(index, 1)]) + }, + } + return subscription + }, + } as MenuProviderType + }, []) + + return ( + <MenuContext.Provider value={value}> + <div onContextMenu={handleContextMenu} onClick={handleClick} style={{ height: "100vh" }}> + <div + className={`menu-container dropdown-menu ${showMenu ? "show" : ""}`} + style={{ position: "absolute", top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }} + > + { + menuItems.map((item, i) => { + // for each new action array (for each new subscription entity) draw a seperator line except the last action + const seperator = i < menuItems.length - 1 ? (<li><hr className="dropdown-divider"></hr></li>) : (<React.Fragment key={i}></React.Fragment>) + + const dropdownItems = item.actions.map(({ action, icon, key }) => { + + const disabled = !(clickedHtmlElement instanceof HTMLElement) || !clickedHtmlElement?.textContent + + return ( + <button className="menu-button dropdown-item" key={key + " " + i} disabled={disabled} onClick={() => action(clickedHtmlElement)}> + <FontAwesomeIcon icon={icon} size="sm" /> + <span>{key}</span> + </button> + ) + }) + + return [...dropdownItems, seperator] + }) + } + </div> + {children} + </div> + </MenuContext.Provider> + ) +} + +export const useMenu = () => { + return useContext(MenuContext) +} \ No newline at end of file diff --git a/react-ui/src/shared/utils/functions.ts b/react-ui/src/shared/utils/functions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4cd80c172ac82f49a5a74c0cf937149e78e0483 --- /dev/null +++ b/react-ui/src/shared/utils/functions.ts @@ -0,0 +1,8 @@ +import { t } from "i18next"; +import { toast } from "react-toastify"; + + +export const toClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.info(t('global.toast.copied')) +} \ No newline at end of file