Skip to content
Snippets Groups Projects
Commit 15b1cfec authored by Matthias Feyll's avatar Matthias Feyll :cookie:
Browse files

(ui): Add custom scroll bar

See merge request !1204
parent 97fffff8
No related branches found
No related tags found
2 merge requests!1206UI-Integration: add first version to master,!1205UI-Integration: add the first working version to master
Pipeline #260975 passed
Showing
with 574 additions and 387 deletions
{
"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
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 { 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'
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 */}
<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">
<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>
</Scrollbar>
</div>
)
}
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>
)
}
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>
);
};
// 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 { DeviceList } from "./device.view.list";
import { DeviceListCollapsable } from "./subcomponent/device.view.list-detail";
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 (
<GridLayout>
<>
<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>
......@@ -62,6 +35,7 @@ const DeviceView = () => {
<GridBox
title={t("device.box.information.title")}
title_icon={faCircleInfo}
disabled={!selected?.device}
>
<Row>
<Col xs={12}>
......@@ -77,6 +51,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>
......
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
// 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
......@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
export const useDeviceTableViewModel = (searchRef, listRef) => {
export const useInformationViewModel = (searchRef, listRef) => {
const [searchTerm, setSearchTerm] = useState('');
const dispatch = useAppDispatch();
const { subscribe } = useMenu();
......
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 {
......
......@@ -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",
......
......@@ -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 {
......
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;
title: string
title_icon: IconDefinition
children: React.ReactNode
className?: string
disabled?: boolean
}
export const GridBox: React.FC<GridBoxProps> = ({
children,
title,
title_icon,
className = "",
children,
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}`}
>
<div>
<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">
<FontAwesomeIcon
icon={title_icon}
size="1x"
className="me-2 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>
);
};
)
}
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
}
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>
)
}
@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;
}
}
......@@ -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;
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment