diff --git a/client/DEVELOPER.md b/client/DEVELOPER.md index c87e41d62..7d4c9061c 100644 --- a/client/DEVELOPER.md +++ b/client/DEVELOPER.md @@ -212,44 +212,11 @@ This token is required to trigger pipelines by using the API. You can create this token in your GitLab project's CI/CD settings under the *Pipeline trigger tokens* section. -Once the token configuration is in place, the gitlab code can be developed +Once the token configuration is in place, the gitlab integration can be developed and tested using the following yarn commands. ```bash -yarn gitlab:compile -yarn gitlab:run +yarn test:preview:int +yarn test:preview:unit ``` -These two commands run `src/util/gitlabDriver.ts` code to check the correct -functioning of the gitlab code placed in `src/util` directory. - -A piece of code in `src/util/gitlabDriver.ts` checks for correct execution -of a DT in gitlab runner. The token owner must have a hosted runner -in order for this piece of code to be executed successfully. -Otherwise, the following error appears. - -```log -.... -Execution Result: false -Execution Status: error -Execution Logs: [ - { - status: 'error', - error: Error: GitbeakerRequestError: Not Found - at DigitalTwin. (file:///C:/Users/au598657/git/DTaaS/client/dist/gitlabDigitalTwin.js:32:73) - at Generator.throw () - at rejected (file:///C:/Users/au598657/git/DTaaS/client/dist/gitlabDigitalTwin.js:5:65) - at process.processTicksAndRejections (node:internal/process/task_queues:95:5), - DTName: 'hello-world', - runnerTag: 'dtaas' - } -] -``` - -## Digital Twins page preview - -In the Workbench section, there is a link to preview the **Digital Twins** -page. The GitLab account used as OAuth provider must have a *DTaaS* group, -a project under your username, and a *digital_twins* folder which contains -the Digital Twins. From this interface, you can start or stop execution of -Digital Twins, and once the execution is complete, view the complete logs. diff --git a/client/README.md b/client/README.md index ba39b4227..d56fa3f4c 100644 --- a/client/README.md +++ b/client/README.md @@ -131,4 +131,18 @@ the following command: sudo gitlab-runner run ``` -It can also be used to reactivate offline runners during subsequent sessions. \ No newline at end of file +It can also be used to reactivate offline runners during subsequent sessions. + +## Digital Twins page preview + +In the Workbench section, there is a link to preview the **Digital Twins** +page. The GitLab account used as OAuth provider must have a *DTaaS* group, +a project under your username, and a *digital_twins* folder which contains +the Digital Twins. + +In the Manage tab, you can read the README.md file of the selected Digital +Twin, reconfigure the content of files included in its GitLab folder or +delete the entire folder. + +In the Execute tab, you can start or stop execution of Digital Twins, and +once the execution is complete, view the complete logs. diff --git a/client/config/gitlab.json b/client/config/gitlab.json deleted file mode 100644 index b35b7e2ef..000000000 --- a/client/config/gitlab.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "username": "username", - "host": "https://foo.com/gitlab", - "oauth_token": "xxxxxxxxxxxxxxxxxxxx" -} diff --git a/client/config/test.js b/client/config/test.js index 5d4dfbd71..8e8f4fb5a 100644 --- a/client/config/test.js +++ b/client/config/test.js @@ -10,8 +10,8 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', - REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', - REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', + REACT_APP_CLIENT_ID: '38bf4764fad5ebb2ebbf49b4f57c7720145b61266f13bf4891ff7851dd5c6563', + REACT_APP_AUTH_AUTHORITY: 'https://maestro.cps.digit.au.dk/gitlab', REACT_APP_REDIRECT_URI: 'http://localhost:4000/Library', REACT_APP_LOGOUT_REDIRECT_URI: 'http://localhost:4000/', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', diff --git a/client/jest.config.json b/client/jest.config.json index cecfe0a5e..84534d9e5 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -51,6 +51,7 @@ "/src/" ], "moduleNameMapper": { - "^test/(.*)$": "/test/$1" + "^test/(.*)$": "/test/$1", + "\\.(css|less|scss)$": "/test/preview/__mocks__/styleMock.ts" } -} \ No newline at end of file +} diff --git a/client/package.json b/client/package.json index 963ec539b..a32c44939 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/dtaas-web", - "version": "0.5.0", + "version": "0.6.0", "description": "Web client for Digital Twin as a Service (DTaaS)", "main": "index.tsx", "author": "prasadtalasila (http://prasad.talasila.in/)", @@ -24,8 +24,6 @@ "config:test": "npx shx cp config/test.js public/env.js && npx shx cp config/test.js build/env.js", "develop": "npx react-scripts start", "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", - "gitlab:compile": "npx tsc --project tsconfig.gitlab.json", - "gitlab:run": "node dist/src/preview/util/gitlabDriver.js", "graph": "npx madge --image src.svg src && npx madge --image test.svg test", "start": "serve -s build -l 4000", "stop": "npx kill-port 4000", @@ -51,9 +49,13 @@ "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^5.0.8", "@gitbeaker/rest": "^40.1.2", + "@monaco-editor/react": "^4.6.0", "@mui/icons-material": "^5.14.8", "@mui/material": "^5.14.8", + "@mui/x-tree-view": "^7.19.0", "@reduxjs/toolkit": "^1.9.7", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/remarkable": "^2.0.8", "@types/styled-components": "^5.1.32", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", @@ -66,6 +68,8 @@ "eslint-plugin-jest": "^27.6.0", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.33.2", + "katex": "^0.16.11", + "markdown-it-katex": "^2.0.3", "oidc-client-ts": "^2.2.2", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -76,8 +80,11 @@ "react-redux": "^8.1.3", "react-router-dom": "^6.20.0", "react-scripts": "^5.0.1", + "react-syntax-highlighter": "^15.5.0", "react-tabs": "^6.0.2", "redux": "^4.2.1", + "remarkable": "^2.0.1", + "remarkable-katex": "^1.2.1", "resize-observer-polyfill": "^1.5.1", "serve": "^14.2.1", "styled-components": "^6.1.1", diff --git a/client/src/page/Layout.tsx b/client/src/page/Layout.tsx index c98cae179..9f44f8f08 100644 --- a/client/src/page/Layout.tsx +++ b/client/src/page/Layout.tsx @@ -27,13 +27,16 @@ function MenuLayout(props: { children: React.ReactNode }) { ); } -function Layout(props: { children: React.ReactNode }) { +function Layout(props: { + children: React.ReactNode; + sx?: React.CSSProperties; +}) { return ( diff --git a/client/src/preview/components/asset/Asset.ts b/client/src/preview/components/asset/Asset.ts index 70a8e8717..3210e1a13 100644 --- a/client/src/preview/components/asset/Asset.ts +++ b/client/src/preview/components/asset/Asset.ts @@ -1,5 +1,4 @@ export interface Asset { name: string; - description?: string; path: string; } diff --git a/client/src/preview/components/asset/AssetBoard.tsx b/client/src/preview/components/asset/AssetBoard.tsx index 4271cd58d..31d0b947e 100644 --- a/client/src/preview/components/asset/AssetBoard.tsx +++ b/client/src/preview/components/asset/AssetBoard.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { Grid } from '@mui/material'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { RootState } from 'store/store'; -import AssetCardExecute from './AssetCard'; +import { deleteAsset } from 'preview/store/assets.slice'; +import { AssetCardExecute, AssetCardManage } from './AssetCard'; import { Asset } from './Asset'; const outerGridContainerProps = { @@ -24,7 +25,8 @@ interface AssetBoardProps { const AssetGridItem: React.FC<{ asset: Asset; tab: string; -}> = ({ asset }) => ( + onDelete: (path: string) => void; +}> = ({ asset, tab, onDelete }) => ( - + {tab === 'Execute' ? ( + + ) : ( + onDelete(asset.path)} /> + )} ); const AssetBoard: React.FC = ({ tab, error }) => { const assets = useSelector((state: RootState) => state.assets.items); + const dispatch = useDispatch(); + + const handleDelete = (deletedAssetPath: string) => { + dispatch(deleteAsset(deletedAssetPath)); + }; if (error) { return {error}; @@ -47,8 +58,13 @@ const AssetBoard: React.FC = ({ tab, error }) => { return ( - {assets.map((asset: Asset) => ( - + {assets.map((asset) => ( + ))} ); diff --git a/client/src/preview/components/asset/AssetCard.tsx b/client/src/preview/components/asset/AssetCard.tsx index 59cad1ee7..1d0df1937 100644 --- a/client/src/preview/components/asset/AssetCard.tsx +++ b/client/src/preview/components/asset/AssetCard.tsx @@ -11,15 +11,34 @@ import { useSelector } from 'react-redux'; import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; import { RootState } from 'store/store'; import LogDialog from 'preview/route/digitaltwins/execute/LogDialog'; +import DetailsDialog from 'preview/route/digitaltwins/manage/DetailsDialog'; +import ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; +import DeleteDialog from 'preview/route/digitaltwins/manage/DeleteDialog'; import StartStopButton from './StartStopButton'; import LogButton from './LogButton'; import { Asset } from './Asset'; +import DetailsButton from './DetailsButton'; +import ReconfigureButton from './ReconfigureButton'; +import DeleteButton from './DeleteButton'; interface AssetCardProps { asset: Asset; buttons?: React.ReactNode; } +interface AssetCardManageProps { + asset: Asset; + buttons?: React.ReactNode; + onDelete: () => void; +} + +interface CardButtonsContainerManageProps { + assetName: string; + setShowDetails: Dispatch>; + setShowReconfigure: Dispatch>; + setShowDelete: Dispatch>; +} + interface CardButtonsContainerExecuteProps { assetName: string; setShowLog: Dispatch>; @@ -67,6 +86,21 @@ function CardActionAreaContainer(asset: Asset) { ); } +function CardButtonsContainerManage({ + assetName, + setShowDetails, + setShowReconfigure, + setShowDelete, +}: CardButtonsContainerManageProps) { + return ( + + + + + + ); +} + function CardButtonsContainerExecute({ assetName, setShowLog, @@ -105,6 +139,48 @@ function AssetCard({ asset, buttons }: AssetCardProps) { ); } +function AssetCardManage({ asset, onDelete }: AssetCardManageProps) { + const [showDetailsLog, setShowDetailsLog] = useState(false); + const [showDeleteLog, setShowDeleteLog] = useState(false); + const [showReconfigure, setShowReconfigure] = useState(false); + const digitalTwin = useSelector(selectDigitalTwinByName(asset.name)); + + return ( + digitalTwin && ( + <> + + } + /> + + + + + + ) + ); +} + function AssetCardExecute({ asset }: AssetCardProps) { useState('success'); const [showLog, setShowLog] = useState(false); @@ -133,4 +209,4 @@ function AssetCardExecute({ asset }: AssetCardProps) { ); } -export default AssetCardExecute; +export { AssetCardManage, AssetCardExecute }; diff --git a/client/src/preview/components/asset/DeleteButton.tsx b/client/src/preview/components/asset/DeleteButton.tsx new file mode 100644 index 000000000..d1bc0d9e4 --- /dev/null +++ b/client/src/preview/components/asset/DeleteButton.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { Button } from '@mui/material'; + +interface DeleteButtonProps { + setShowDelete: Dispatch>; +} + +const handleToggleDeleteDialog = ( + setShowDelete: Dispatch>, +) => { + setShowDelete(true); +}; + +function DeleteButton({ setShowDelete }: DeleteButtonProps) { + return ( + + ); +} + +export default DeleteButton; diff --git a/client/src/preview/components/asset/DetailsButton.tsx b/client/src/preview/components/asset/DetailsButton.tsx new file mode 100644 index 000000000..d2ac213f5 --- /dev/null +++ b/client/src/preview/components/asset/DetailsButton.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { Button } from '@mui/material'; +import { useSelector } from 'react-redux'; +import { selectDigitalTwinByName } from '../../store/digitalTwin.slice'; +import DigitalTwin from '../../util/gitlabDigitalTwin'; + +interface DialogButtonProps { + assetName: string; + setShowDetails: Dispatch>; +} + +export const handleToggleDetailsDialog = async ( + digitalTwin: DigitalTwin, + setShowDetails: Dispatch>, +) => { + await digitalTwin.getFullDescription(); + setShowDetails(true); +}; + +function DetailsButton({ assetName, setShowDetails }: DialogButtonProps) { + const digitalTwin = useSelector(selectDigitalTwinByName(assetName)); + return ( + + ); +} + +export default DetailsButton; diff --git a/client/src/preview/components/asset/ReconfigureButton.tsx b/client/src/preview/components/asset/ReconfigureButton.tsx new file mode 100644 index 000000000..9e4589f77 --- /dev/null +++ b/client/src/preview/components/asset/ReconfigureButton.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { Button } from '@mui/material'; +import { Dispatch, SetStateAction } from 'react'; + +interface ReconfigureButtonProps { + setShowReconfigure: Dispatch>; +} + +export const handleToggleReconfigureDialog = ( + setShowReconfigure: Dispatch>, +) => { + setShowReconfigure((prev) => !prev); +}; + +function ReconfigureButton({ setShowReconfigure }: ReconfigureButtonProps) { + return ( + + ); +} + +export default ReconfigureButton; diff --git a/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts b/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts index b1d0b344c..2c1dd08d0 100644 --- a/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts +++ b/client/src/preview/route/digitaltwins/DigitalTwinTabDataPreview.ts @@ -7,11 +7,11 @@ const tabs: ITabs[] = [ }, { label: 'Manage', - body: `Read the complete description of digital twins. If necessary, users can delete a digital twin, removing it from the workspace with all its associated data. Users can also reconfigure the digital twin, through a link that redirects to the Create tab.`, + body: `Read the complete description of digital twins. If necessary, users can delete a digital twin, removing it from the workspace with all its associated data. Users can also reconfigure the digital twin.`, }, { label: 'Execute', - body: 'This page demonstrates integration of DTaaS with gitlab CI/CD workflows. The feature is experimental and requires certain gitlab setup in order for it to work.', + body: 'Execute the Digital Twins using Gitlab CI/CD workflows.', }, { label: 'Analyze', diff --git a/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx b/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx index e825d8d38..0623ae941 100644 --- a/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx +++ b/client/src/preview/route/digitaltwins/DigitalTwinsPreview.tsx @@ -16,7 +16,7 @@ import tabs from './DigitalTwinTabDataPreview'; export const createDTTab = (error: string | null): TabData[] => tabs - .filter((tab) => tab.label === 'Execute') + .filter((tab) => tab.label === 'Manage' || tab.label === 'Execute') .map((tab) => ({ label: tab.label, body: ( @@ -61,7 +61,7 @@ export const createDigitalTwinsForAssets = async ( ); await gitlabInstance.init(); const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); - digitalTwin.description = asset.description; + await digitalTwin.getDescription(); dispatch(setDigitalTwin({ assetName: asset.name, digitalTwin })); }); }; @@ -85,6 +85,11 @@ export const DTContent = () => { return ( + + This page demonstrates integration of DTaaS with gitlab CI/CD workflows. + The feature is experimental and requires certain gitlab setup in order + for it to work. + ); diff --git a/client/src/preview/route/digitaltwins/editor/Editor.tsx b/client/src/preview/route/digitaltwins/editor/Editor.tsx new file mode 100644 index 000000000..30dcda0f7 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/Editor.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { Box, Grid, Tabs, Tab } from '@mui/material'; +// import { Resizable } from 'react-resizable'; +import EditorTab from './EditorTab'; +import PreviewTab from './PreviewTab'; +import Sidebar from './Sidebar'; + +interface EditorProps { + DTName: string; +} + +function Editor({ DTName }: EditorProps) { + const [activeTab, setActiveTab] = useState(0); + const [fileName, setFileName] = useState(''); + const [fileContent, setFileContent] = useState(''); + const [fileType, setFileType] = useState(''); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + + + + + + + + + {activeTab === 0 && ( + + )} + {activeTab === 1 && ( + + )} + + + + ); +} + +export default Editor; diff --git a/client/src/preview/route/digitaltwins/editor/EditorTab.tsx b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx new file mode 100644 index 000000000..885367f6d --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; +import Editor from '@monaco-editor/react'; +import { useDispatch } from 'react-redux'; +import { addOrUpdateFile } from '../../../store/file.slice'; + +interface EditorTabProps { + fileName: string; + fileContent: string; + setFileContent: Dispatch>; +} + +const handleEditorChange = ( + value: string | undefined, + setEditorValue: Dispatch>, + setFileContent: Dispatch>, + fileName: string, + dispatch: ReturnType, +) => { + const updatedValue = value || ''; + setEditorValue(updatedValue); + setFileContent(updatedValue); + + dispatch( + addOrUpdateFile({ + name: fileName, + content: updatedValue, + isModified: true, + }), + ); +}; + +function EditorTab({ fileName, fileContent, setFileContent }: EditorTabProps) { + const [editorValue, setEditorValue] = useState(fileContent); + const dispatch = useDispatch(); + + useEffect(() => { + setEditorValue(fileContent); + }, [fileContent]); + + return ( +
+ + handleEditorChange( + value, + setEditorValue, + setFileContent, + fileName, + dispatch, + ) + } + /> +
+ ); +} + +export default EditorTab; diff --git a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx new file mode 100644 index 000000000..31acd62f8 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { Remarkable } from 'remarkable'; +import 'katex/dist/katex.min.css'; +// @ts-expect-error: Ignoring TypeScript error due to missing type definitions for 'remarkable-katex'. +import * as RemarkableKatex from 'remarkable-katex'; +import SyntaxHighlighter from 'react-syntax-highlighter'; + +interface PreviewProps { + fileContent: string; + fileType: string; +} + +function PreviewTab({ fileContent, fileType }: PreviewProps) { + if (fileType === 'md') { + const md = new Remarkable({ + html: true, + typographer: true, + }).use(RemarkableKatex); + + const renderedMarkdown = md.render(fileContent); + + return ( +
+
+ +
+ ); + } + + if (fileType === 'json') { + return {fileContent}; + } + if (fileType === 'yaml' || fileType === 'yml') { + return {fileContent}; + } + return {fileContent}; +} + +export default PreviewTab; diff --git a/client/src/preview/route/digitaltwins/editor/Sidebar.tsx b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx new file mode 100644 index 000000000..9a8977a1b --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import { Grid, CircularProgress } from '@mui/material'; +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; +import { TreeItem } from '@mui/x-tree-view/TreeItem'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import { FileState } from '../../../store/file.slice'; +import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; +import DigitalTwin from '../../../util/gitlabDigitalTwin'; + +interface SidebarProps { + name: string; + setFileName: Dispatch>; + setFileContent: Dispatch>; + setFileType: Dispatch>; +} + +const fetchData = async (digitalTwin: DigitalTwin) => { + await digitalTwin.getDescriptionFiles(); + await digitalTwin.getLifecycleFiles(); + await digitalTwin.getConfigFiles(); +}; + +export const handleFileClick = async ( + fileName: string, + digitalTwin: DigitalTwin, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + modifiedFiles: FileState[], +) => { + const modifiedFile = modifiedFiles.find((file) => file.name === fileName); + + if (modifiedFile) { + updateFileState( + modifiedFile.name, + modifiedFile.content, + setFileName, + setFileContent, + setFileType, + ); + } else { + const fileContent = await digitalTwin.getFileContent(fileName); + if (fileContent) { + updateFileState( + fileName, + fileContent, + setFileName, + setFileContent, + setFileType, + ); + } else { + setFileContent(`Error fetching ${fileName} content`); + } + } +}; + +const updateFileState = ( + fileName: string, + fileContent: string, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, +) => { + setFileName(fileName); + setFileContent(fileContent); + setFileType(fileName.split('.').pop()!); +}; + +const Sidebar = ({ + name, + setFileName, + setFileContent, + setFileType, +}: SidebarProps) => { + const digitalTwin = useSelector(selectDigitalTwinByName(name)); + const modifiedFiles = useSelector((state: RootState) => state.files); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + await fetchData(digitalTwin); + setIsLoading(false); + }; + + loadData(); + }, [digitalTwin]); + + const renderFileTreeItems = (label: string, files: string[]) => ( + + {files.map((item, id) => ( + + handleFileClick( + item, + digitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ) + } + /> + ))} + + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + {renderFileTreeItems('Description', digitalTwin.descriptionFiles)} + {renderFileTreeItems('Configuration', digitalTwin.configFiles)} + {renderFileTreeItems('Lifecycle', digitalTwin.lifecycleFiles)} + + + ); +}; + +export default Sidebar; diff --git a/client/src/preview/route/digitaltwins/manage/DeleteDialog.tsx b/client/src/preview/route/digitaltwins/manage/DeleteDialog.tsx new file mode 100644 index 000000000..a3f2c0887 --- /dev/null +++ b/client/src/preview/route/digitaltwins/manage/DeleteDialog.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { + Dialog, + DialogContent, + DialogActions, + Button, + Typography, +} from '@mui/material'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; +import DigitalTwin, { formatName } from '../../../util/gitlabDigitalTwin'; +import { showSnackbar } from '../../../store/snackbar.slice'; + +interface DeleteDialogProps { + showDialog: boolean; + setShowDialog: Dispatch>; + name: string; + onDelete: () => void; +} + +const handleCloseDeleteDialog = ( + setShowLog: Dispatch>, +) => { + setShowLog(false); +}; + +const handleDelete = async ( + digitalTwin: DigitalTwin, + setShowLog: Dispatch>, + onDelete: () => void, + dispatch: ReturnType, +) => { + const returnMessage = await digitalTwin.delete(); + onDelete(); + setShowLog(false); + dispatch( + showSnackbar({ + message: returnMessage, + severity: returnMessage.includes('Error') ? 'error' : 'success', + }), + ); +}; + +function DeleteDialog({ + showDialog, + setShowDialog, + name, + onDelete, +}: DeleteDialogProps) { + const dispatch = useDispatch(); + const digitalTwin = useSelector(selectDigitalTwinByName(name)); + return ( + + + + This step is irreversible. Would you like to delete{' '} + {formatName(name)} digital twin? + + + + + + + + ); +} + +export default DeleteDialog; diff --git a/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx new file mode 100644 index 000000000..145cb9662 --- /dev/null +++ b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Dispatch, SetStateAction } from 'react'; +import { Dialog, DialogContent, DialogActions, Button } from '@mui/material'; +import { Remarkable } from 'remarkable'; +import 'katex/dist/katex.min.css'; +// @ts-expect-error: Ignoring TypeScript error due to missing type definitions for 'remarkable-katex'. +import * as RemarkableKatex from 'remarkable-katex'; +import { useSelector } from 'react-redux'; +import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; + +interface DetailsDialogProps { + showDialog: boolean; + setShowDialog: Dispatch>; + name: string; +} + +const handleCloseDetailsDialog = ( + setShowLog: Dispatch>, +) => { + setShowLog(false); +}; + +function DetailsDialog({ + showDialog, + setShowDialog, + name, +}: DetailsDialogProps) { + const digitalTwin = useSelector(selectDigitalTwinByName(name)); + + const md = new Remarkable({ + html: true, + typographer: true, + }).use(RemarkableKatex); + + return ( + + +
+ + + + + +
+ ); +} + +export default DetailsDialog; diff --git a/client/src/preview/route/digitaltwins/manage/ReconfigureDialog.tsx b/client/src/preview/route/digitaltwins/manage/ReconfigureDialog.tsx new file mode 100644 index 000000000..411564282 --- /dev/null +++ b/client/src/preview/route/digitaltwins/manage/ReconfigureDialog.tsx @@ -0,0 +1,206 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + +import * as React from 'react'; +import { useState, Dispatch, SetStateAction } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + AlertColor, +} from '@mui/material'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import { FileState, saveAllFiles } from '../../../store/file.slice'; +import { + selectDigitalTwinByName, + updateDescription, +} from '../../../store/digitalTwin.slice'; +import { showSnackbar } from '../../../store/snackbar.slice'; +import DigitalTwin, { formatName } from '../../../util/gitlabDigitalTwin'; +import Editor from '../editor/Editor'; + +interface ReconfigureDialogProps { + showDialog: boolean; + setShowDialog: Dispatch>; + name: string; +} + +export const handleCloseReconfigureDialog = ( + setShowDialog: Dispatch>, +) => { + setShowDialog(false); +}; + +function ReconfigureDialog({ + showDialog, + setShowDialog, + name, +}: ReconfigureDialogProps) { + const [openSaveDialog, setOpenSaveDialog] = useState(false); + const [openCancelDialog, setOpenCancelDialog] = useState(false); + const digitalTwin = useSelector(selectDigitalTwinByName(name)); + const modifiedFiles = useSelector((state: RootState) => state.files); + const dispatch = useDispatch(); + + const handleSave = () => setOpenSaveDialog(true); + const handleCancel = () => setOpenCancelDialog(true); + const handleCloseSaveDialog = () => setOpenSaveDialog(false); + const handleCloseCancelDialog = () => setOpenCancelDialog(false); + + const handleConfirmSave = async () => { + await saveChanges(modifiedFiles, digitalTwin, dispatch, name); + setOpenSaveDialog(false); + setShowDialog(false); + }; + + const handleConfirmCancel = () => { + setOpenCancelDialog(false); + setShowDialog(false); + }; + + return ( + <> + + + + + + + ); +} + +export const saveChanges = async ( + modifiedFiles: FileState[], + digitalTwin: DigitalTwin, + dispatch: ReturnType, + name: string, +) => { + for (const file of modifiedFiles) { + await handleFileUpdate(file, digitalTwin, dispatch); + } + + showSuccessSnackbar(dispatch, name); + dispatch(saveAllFiles()); +}; + +export const handleFileUpdate = async ( + file: FileState, + digitalTwin: DigitalTwin, + dispatch: ReturnType, +) => { + try { + await digitalTwin.updateFileContent(file.name, file.content); + + if (file.name === 'description.md') { + dispatch( + updateDescription({ + assetName: digitalTwin.DTName, + description: file.content, + }), + ); + } + } catch (error) { + dispatch( + showSnackbar({ + message: `Error updating file ${file.name}: ${error}`, + severity: 'error', + }), + ); + } +}; + +const showSuccessSnackbar = ( + dispatch: ReturnType, + name: string, +) => { + dispatch( + showSnackbar({ + message: `${formatName(name)} reconfigured successfully`, + severity: 'success' as AlertColor, + }), + ); +}; + +const ReconfigureMainDialog = ({ + showDialog, + setShowDialog, + name, + handleCancel, + handleSave, +}: { + showDialog: boolean; + setShowDialog: Dispatch>; + name: string; + handleCancel: () => void; + handleSave: () => void; +}) => ( + handleCloseReconfigureDialog(setShowDialog)} + fullWidth={true} + maxWidth="lg" + sx={{ + '& .MuiDialog-paper': { + maxHeight: '65vh', + }, + }} + > + + Reconfigure {formatName(name)} + + + + + + + + + +); + +const ConfirmationDialog = ({ + open, + onClose, + onConfirm, + content, +}: { + open: boolean; + onClose: () => void; + onConfirm: () => void; + content: string; +}) => ( + + {content} + + + + + +); + +export default ReconfigureDialog; diff --git a/client/src/preview/store/assets.slice.ts b/client/src/preview/store/assets.slice.ts index ae89c6d02..085403f20 100644 --- a/client/src/preview/store/assets.slice.ts +++ b/client/src/preview/store/assets.slice.ts @@ -16,9 +16,14 @@ const assetsSlice = createSlice({ setAssets: (state, action: PayloadAction) => { state.items = action.payload; }, + deleteAsset: (state, action: PayloadAction) => { + state.items = state.items.filter( + (asset) => asset.path !== action.payload, + ); + }, }, }); -export const { setAssets } = assetsSlice.actions; +export const { setAssets, deleteAsset } = assetsSlice.actions; export default assetsSlice.reducer; diff --git a/client/src/preview/store/digitalTwin.slice.ts b/client/src/preview/store/digitalTwin.slice.ts index ec836273f..f92dec7e4 100644 --- a/client/src/preview/store/digitalTwin.slice.ts +++ b/client/src/preview/store/digitalTwin.slice.ts @@ -46,6 +46,15 @@ const digitalTwinSlice = createSlice({ digitalTwin.pipelineLoading = action.payload.pipelineLoading; } }, + updateDescription: ( + state, + action: PayloadAction<{ assetName: string; description: string }>, + ) => { + const digitalTwin = state[action.payload.assetName]; + if (digitalTwin) { + digitalTwin.description = action.payload.description; + } + }, }, }); @@ -57,5 +66,6 @@ export const { setJobLogs, setPipelineCompleted, setPipelineLoading, + updateDescription, } = digitalTwinSlice.actions; export default digitalTwinSlice.reducer; diff --git a/client/src/preview/store/file.slice.ts b/client/src/preview/store/file.slice.ts new file mode 100644 index 000000000..c5aebaaa4 --- /dev/null +++ b/client/src/preview/store/file.slice.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface FileState { + name: string; + content: string; + isModified: boolean; +} + +const initialState: FileState[] = []; + +const filesSlice = createSlice({ + name: 'files', + initialState, + reducers: { + addOrUpdateFile: (state, action: PayloadAction) => { + const index = state.findIndex( + (file) => file.name === action.payload.name, + ); + if (index >= 0) { + state[index] = { ...action.payload, isModified: true }; + } else { + state.push({ ...action.payload, isModified: true }); + } + }, + + saveAllFiles: (state) => { + const filesToSave = state.filter((file) => file.isModified); + filesToSave.forEach((file) => { + const index = state.findIndex((f) => f.name === file.name); + if (index >= 0) { + state.splice(index, 1); + } + }); + }, + }, +}); + +export const { addOrUpdateFile, saveAllFiles } = filesSlice.actions; +export default filesSlice.reducer; diff --git a/client/src/preview/util/gitlab.ts b/client/src/preview/util/gitlab.ts index 7aa965909..e558782bb 100644 --- a/client/src/preview/util/gitlab.ts +++ b/client/src/preview/util/gitlab.ts @@ -68,16 +68,6 @@ class GitlabInstance { return token; } - async getDTDescription(DTName: string): Promise { - const readmePath = `digital_twins/${DTName}/description.md`; - const fileData = await this.api.RepositoryFiles.show( - this.projectId!, - readmePath, - 'main', - ); - return atob(fileData.content); - } - async getDTSubfolders(projectId: number): Promise { const files = await this.api.Repositories.allRepositoryTrees(projectId, { path: DT_DIRECTORY, @@ -90,7 +80,6 @@ class GitlabInstance { .map(async (file) => ({ name: file.name, path: file.path, - description: await this.getDTDescription(file.name), })), ); diff --git a/client/src/preview/util/gitlabDigitalTwin.ts b/client/src/preview/util/gitlabDigitalTwin.ts index b3cd4305c..a0a08e5be 100644 --- a/client/src/preview/util/gitlabDigitalTwin.ts +++ b/client/src/preview/util/gitlabDigitalTwin.ts @@ -1,3 +1,4 @@ +import { getAuthority } from 'util/envUtil'; import GitlabInstance from './gitlab'; const RUNNER_TAG = 'linux'; @@ -26,6 +27,8 @@ class DigitalTwin { public descriptionFiles: string[] = []; + public lifecycleFiles: string[] = []; + public configFiles: string[] = []; constructor(DTName: string, gitlabInstance: GitlabInstance) { @@ -33,16 +36,39 @@ class DigitalTwin { this.gitlabInstance = gitlabInstance; } + async getDescription(): Promise { + if (this.gitlabInstance.projectId) { + const descriptionPath = `digital_twins/${this.DTName}/description.md`; + try { + const fileData = await this.gitlabInstance.api.RepositoryFiles.show( + this.gitlabInstance.projectId, + descriptionPath, + 'main', + ); + this.description = atob(fileData.content); + } catch (error) { + this.description = `There is no description.md file in the ${this.DTName} GitLab folder`; + } + } + } + async getFullDescription(): Promise { if (this.gitlabInstance.projectId) { const readmePath = `digital_twins/${this.DTName}/README.md`; + const imagesPath = `digital_twins/${this.DTName}/`; try { const fileData = await this.gitlabInstance.api.RepositoryFiles.show( this.gitlabInstance.projectId, readmePath, 'main', ); - this.fullDescription = atob(fileData.content); + this.fullDescription = atob(fileData.content).replace( + /(!\[[^\]]*\])\(([^)]+)\)/g, + (match, altText, imagePath) => { + const fullUrl = `${getAuthority()}/dtaas/${sessionStorage.getItem('username')}/-/raw/main/${imagesPath}${imagePath}`; + return `${altText}(${fullUrl})`; + }, + ); } catch (error) { this.fullDescription = `There is no README.md file in the ${this.DTName} GitLab folder`; } @@ -124,6 +150,131 @@ class DigitalTwin { this.lastExecutionStatus = 'error'; } } + + async delete() { + if (this.gitlabInstance.projectId) { + const digitalTwinPath = `digital_twins/${this.DTName}`; + try { + await this.gitlabInstance.api.RepositoryFiles.remove( + this.gitlabInstance.projectId, + digitalTwinPath, + 'main', + `Removing ${this.DTName} digital twin`, + ); + return `${this.DTName} deleted successfully`; + } catch (error) { + return `Error deleting ${this.DTName} digital twin`; + } + } + return `Error deleting ${this.DTName} digital twin: no project id`; + } + + async getDescriptionFiles() { + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + this.gitlabInstance.projectId!, + { + path: `digital_twins/${this.DTName}`, + recursive: true, + }, + ); + + const filteredFiles = response + .filter( + (item: { type: string; name: string; path: string }) => + item.type === 'blob' && item.name.endsWith('.md'), + ) + .map((file: { name: string }) => file.name); + + this.descriptionFiles = filteredFiles; + } catch (error) { + this.descriptionFiles = []; + } + } + + async getLifecycleFiles() { + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + this.gitlabInstance.projectId!, + { + path: `digital_twins/${this.DTName}`, + recursive: true, + }, + ); + + const filteredFiles = response + .filter( + (item: { type: string; name: string; path: string }) => + item.type === 'blob' && item.path.includes('/lifecycle/'), + ) + .map((file: { name: string }) => file.name); + + this.lifecycleFiles = filteredFiles; + } catch (error) { + this.lifecycleFiles = []; + } + } + + async getConfigFiles() { + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + this.gitlabInstance.projectId!, + { + path: `digital_twins/${this.DTName}`, + recursive: false, + }, + ); + + const filteredFiles = response + .filter( + (item: { type: string; name: string }) => + item.type === 'blob' && + (item.name.endsWith('.json') || item.name.endsWith('.yml')), + ) + .map((file: { name: string }) => file.name); + + this.configFiles = filteredFiles; + } catch (error) { + this.configFiles = []; + } + } + + async getFileContent(fileName: string) { + const isFileWithoutExtension = !fileName.includes('.'); + + const filePath = isFileWithoutExtension + ? `digital_twins/${this.DTName}/lifecycle/${fileName}` + : `digital_twins/${this.DTName}/${fileName}`; + + const response = await this.gitlabInstance.api.RepositoryFiles.show( + this.gitlabInstance.projectId!, + filePath, + 'main', + ); + const fileContent = atob(response.content); + return fileContent; + } + + async updateFileContent(fileName: string, fileContent: string) { + const hasExtension = fileName.includes('.'); + + const filePath = hasExtension + ? `digital_twins/${this.DTName}/${fileName}` + : `digital_twins/${this.DTName}/lifecycle/${fileName}`; + + const commitMessage = `Update ${fileName} content`; + + await this.gitlabInstance.api.RepositoryFiles.edit( + this.gitlabInstance.projectId!, + filePath, + 'main', + fileContent, + commitMessage, + ); + } } export default DigitalTwin; diff --git a/client/src/preview/util/gitlabDriver.ts b/client/src/preview/util/gitlabDriver.ts deleted file mode 100644 index 5737b3f9b..000000000 --- a/client/src/preview/util/gitlabDriver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-console */ -import GitlabInstance from './gitlab.js'; -import DigitalTwin from './gitlabDigitalTwin.js'; -import config from '../../../config/gitlab.json' assert { type: 'json' }; - -class GitlabDriver { - public static async run(): Promise { - const gitlabInstance = new GitlabInstance( - config.username, - config.host, - config.oauth_token, - ); - - await gitlabInstance.init(); - - console.log('GitLab username:', gitlabInstance.username); - console.log('GitLab logs:', gitlabInstance.logs); - console.log('GitLab subfolders:', gitlabInstance.subfolders); - - const projectId = (await gitlabInstance.getProjectId()) || 0; - console.log('Project id:', projectId); - - const subfolders = await gitlabInstance.getDTSubfolders(projectId); - console.log('Subfolders:', subfolders); - - const dtName = subfolders[0].name; - - const triggerToken = await gitlabInstance.getTriggerToken(projectId); - console.log('Trigger token:', triggerToken); - - const digitalTwin = new DigitalTwin(dtName, gitlabInstance); - const result = await digitalTwin.execute(); - - console.log('Execution Result:', result); - - console.log('Last execution Status:', digitalTwin.lastExecutionStatus); - - const logs = gitlabInstance.executionLogs(); - console.log('Execution Logs:', logs); - } -} - -GitlabDriver.run().catch((error) => { - console.error('Error executing GitlabDriver:', error); -}); - -export default GitlabDriver; diff --git a/client/src/route/auth/Account.tsx b/client/src/route/auth/Account.tsx index 6b2623ff6..5599983f8 100644 --- a/client/src/route/auth/Account.tsx +++ b/client/src/route/auth/Account.tsx @@ -11,7 +11,7 @@ function AccountContent() { })); return ( - + ); diff --git a/client/src/route/digitaltwins/DigitalTwins.tsx b/client/src/route/digitaltwins/DigitalTwins.tsx index c6f4580f2..f0995d2c3 100644 --- a/client/src/route/digitaltwins/DigitalTwins.tsx +++ b/client/src/route/digitaltwins/DigitalTwins.tsx @@ -24,7 +24,7 @@ function DTContent() { })); return ( - + ); diff --git a/client/src/route/library/Library.tsx b/client/src/route/library/Library.tsx index bf12fb665..94fbc80d5 100644 --- a/client/src/route/library/Library.tsx +++ b/client/src/route/library/Library.tsx @@ -45,7 +45,7 @@ function LibraryContent() { const combinedData = createCombinedTabs(); return ( - + ); diff --git a/client/src/route/workbench/Workbench.tsx b/client/src/route/workbench/Workbench.tsx index 68c550e2a..091e267f7 100644 --- a/client/src/route/workbench/Workbench.tsx +++ b/client/src/route/workbench/Workbench.tsx @@ -15,7 +15,7 @@ const Container = styled.div` function WorkBenchContent() { const linkValues = getWorkbenchLinkValues(); return ( - + ({ diff --git a/client/test/preview/__mocks__/styleMock.ts b/client/test/preview/__mocks__/styleMock.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/client/test/preview/__mocks__/styleMock.ts @@ -0,0 +1 @@ +export default {}; diff --git a/client/test/preview/__mocks__/unit/module_mocks.ts b/client/test/preview/__mocks__/unit/module_mocks.ts new file mode 100644 index 000000000..db400e843 --- /dev/null +++ b/client/test/preview/__mocks__/unit/module_mocks.ts @@ -0,0 +1,5 @@ +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); diff --git a/client/test/preview/integration/components/asset/AssetBoard.test.tsx b/client/test/preview/integration/components/asset/AssetBoard.test.tsx index 2e7dfc1e6..e6030266b 100644 --- a/client/test/preview/integration/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/integration/components/asset/AssetBoard.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import AssetBoard from 'preview/components/asset/AssetBoard'; import { @@ -13,17 +13,23 @@ import digitalTwinReducer, { } from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; import { Asset } from 'preview/components/asset/Asset'; -import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import fileSlice, { + FileState, + addOrUpdateFile, +} from 'preview/store/file.slice'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), })); jest.useFakeTimers(); -const preSetItems: Asset[] = [ - { name: 'Asset 1', description: 'Mocked description', path: 'path/asset1' }, +const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + +const files: FileState[] = [ + { name: 'Asset 1', content: 'content1', isModified: false }, ]; const store = configureStore({ @@ -31,23 +37,23 @@ const store = configureStore({ assets: assetsReducer, digitalTwin: digitalTwinReducer, snackbar: snackbarSlice, + files: fileSlice, }), middleware: getDefaultMiddleware({ serializableCheck: false, }), }); -mockDigitalTwin.description = 'Mocked description'; - describe('AssetBoard Integration Tests', () => { const setupTest = () => { store.dispatch(setAssets(preSetItems)); store.dispatch( setDigitalTwin({ assetName: 'Asset 1', - digitalTwin: mockDigitalTwin, + digitalTwin: new DigitalTwin('Asset 1', mockGitlabInstance), }), ); + store.dispatch(addOrUpdateFile(files[0])); }; beforeEach(() => { @@ -58,24 +64,58 @@ describe('AssetBoard Integration Tests', () => { jest.clearAllMocks(); }); - it('renders AssetBoard with assets', () => { + it('renders AssetBoard with AssetCardExecute', () => { render( - + , ); expect(screen.getByText('Asset 1')).toBeInTheDocument(); - expect(screen.getByText('Mocked description')).toBeInTheDocument(); + }); + + it('renders AssetBoard with AssetCardManage', () => { + render( + + + , + ); + expect(screen.getByText('Asset 1')).toBeInTheDocument(); }); it('renders error message when error is present', () => { render( - + , ); expect(screen.getByText('An error occurred')).toBeInTheDocument(); }); + + it('deletes an asset', async () => { + render( + + + , + ); + + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + expect(deleteButton).toBeInTheDocument(); + + act(() => { + deleteButton.click(); + }); + + const yesButton = await screen.findByRole('button', { name: /Yes/i }); + expect(yesButton).toBeInTheDocument(); + + act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(screen.queryByText('Asset 1')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx index 6fcdd5fcb..5948becb9 100644 --- a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx +++ b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx @@ -4,7 +4,7 @@ import { getDefaultMiddleware, } from '@reduxjs/toolkit'; import { fireEvent, render, screen, act } from '@testing-library/react'; -import AssetCardExecute from 'preview/components/asset/AssetCard'; +import { AssetCardExecute } from 'preview/components/asset/AssetCard'; import * as React from 'react'; import { Provider } from 'react-redux'; import assetsReducer, { setAssets } from 'preview/store/assets.slice'; @@ -37,7 +37,6 @@ describe('AssetCardExecute Integration Test', () => { setAssets([ { name: 'Asset 1', - description: 'Mocked description', path: 'path/asset1', }, ]), diff --git a/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx new file mode 100644 index 000000000..6b3ddbaef --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx @@ -0,0 +1,177 @@ +import Editor from 'preview/route/digitaltwins/editor/Editor'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import fileSlice, { + FileState, + addOrUpdateFile, +} from 'preview/store/file.slice'; +import { Asset } from 'preview/components/asset/Asset'; +import * as React from 'react'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { handleFileClick } from 'preview/route/digitaltwins/editor/Sidebar'; + +describe('Editor', () => { + const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const files = [{ name: 'Asset 1', content: 'content1', isModified: false }]; + + const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + files: fileSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), + }); + + const digitalTwinInstance = new DigitalTwin('Asset 1', mockGitlabInstance); + digitalTwinInstance.descriptionFiles = ['file1.md', 'file2.md']; + digitalTwinInstance.configFiles = ['config1.json', 'config2.json']; + digitalTwinInstance.lifecycleFiles = ['lifecycle1.txt', 'lifecycle2.txt']; + + const setupTest = async () => { + store.dispatch(setAssets(preSetItems)); + store.dispatch( + setDigitalTwin({ + assetName: 'Asset 1', + digitalTwin: digitalTwinInstance, + }), + ); + store.dispatch(addOrUpdateFile(files[0])); + }; + + const dispatchSetDigitalTwin = async (digitalTwin: DigitalTwin) => { + await React.act(async () => { + store.dispatch( + setDigitalTwin({ + assetName: 'Asset 1', + digitalTwin, + }), + ); + }); + }; + + beforeEach(async () => { + await setupTest(); + await React.act(async () => { + await waitFor(() => { + render( + + + , + ); + }); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('changes active tab', () => { + const editorTab = screen.getByRole('tab', { name: 'Editor' }); + const previewTab = screen.getByRole('tab', { name: 'Preview' }); + + expect(editorTab).toHaveAttribute('aria-selected', 'true'); + expect(previewTab).toHaveAttribute('aria-selected', 'false'); + + React.act(() => { + previewTab.click(); + }); + + expect(previewTab).toHaveAttribute('aria-selected', 'true'); + expect(editorTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('should update state when a modified file is clicked', async () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + + const modifiedFiles = [ + { name: 'file1.md', content: 'modified content', isModified: true }, + ]; + + const newDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + + await dispatchSetDigitalTwin(newDigitalTwin); + + await handleFileClick( + 'file1.md', + newDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + + expect(setFileName).toHaveBeenCalledWith('file1.md'); + expect(setFileContent).toHaveBeenCalledWith('modified content'); + expect(setFileType).toHaveBeenCalledWith('md'); + }); + + it('should fetch file content for an unmodified file', async () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + + const modifiedFiles: FileState[] = []; + + const newDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + newDigitalTwin.getFileContent = jest + .fn() + .mockResolvedValueOnce('Fetched content'); + + await dispatchSetDigitalTwin(newDigitalTwin); + + await handleFileClick( + 'file1.md', + newDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + + expect(setFileName).toHaveBeenCalledWith('file1.md'); + expect(setFileContent).toHaveBeenCalledWith('Fetched content'); + expect(setFileType).toHaveBeenCalledWith('md'); + }); + + it('should set error message when fetching file content fails', async () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + + const modifiedFiles: FileState[] = []; + + const newDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + newDigitalTwin.getFileContent = jest.fn().mockResolvedValueOnce(null); + + await dispatchSetDigitalTwin(newDigitalTwin); + + await handleFileClick( + 'file1.md', + newDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching file1.md content', + ); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/editor/PreviewTab.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/PreviewTab.test.tsx new file mode 100644 index 000000000..dfa230c2b --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/editor/PreviewTab.test.tsx @@ -0,0 +1,130 @@ +import { + combineReducers, + configureStore, + createStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import * as React from 'react'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { Provider } from 'react-redux'; +import { render, screen } from '@testing-library/react'; +import fileSlice, { addOrUpdateFile } from 'preview/store/file.slice'; +import PreviewTab from 'preview/route/digitaltwins/editor/PreviewTab'; + +describe('PreviewTab', () => { + let store: ReturnType; + + beforeEach(async () => { + await React.act(async () => { + store = configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + files: fileSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), + }); + + const digitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + digitalTwin.descriptionFiles = ['file1.md', 'file2.md']; + digitalTwin.configFiles = ['config1.json', 'config2.json']; + digitalTwin.lifecycleFiles = ['lifecycle1.txt', 'lifecycle2.txt']; + + store.dispatch( + setDigitalTwin({ + assetName: 'Asset 1', + digitalTwin, + }), + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders Markdown content using md.render', () => { + const markdownContent = '# Heading\nSome **bold** text.'; + + render( + + + , + ); + expect(screen.getByText('Heading')).toBeInTheDocument(); + + expect( + screen.getByText( + (content, element) => + content.startsWith('Some') && element?.tagName === 'P', + ), + ).toBeInTheDocument(); + + expect( + screen.getByText( + (content, element) => + content.startsWith('bold') && element?.tagName === 'STRONG', + ), + ).toBeInTheDocument(); + }); + + it('renders JSON content correctly in Preview tab', async () => { + const jsonFile = { + name: 'config.json', + content: '{"key": "value"}', + isModified: false, + }; + + await React.act(async () => { + store.dispatch(addOrUpdateFile(jsonFile)); + }); + + render( + + + , + ); + + expect(screen.getByText(/"key"/)).toBeInTheDocument(); + expect(screen.getByText(/"value"/)).toBeInTheDocument(); + }); + + it('renders YAML content correctly', () => { + const yamlFile = { + name: 'config.yaml', + content: 'key: value', + isModified: false, + }; + + render( + + + , + ); + + expect(screen.getByText(/key:/)).toBeInTheDocument(); + expect(screen.getByText(/value/)).toBeInTheDocument(); + }); + + it('renders Bash content correctly', () => { + const bashFile = { + name: 'script.sh', + content: 'echo "Hello World"', + isModified: false, + }; + + render( + + + , + ); + + expect(screen.getByText(/echo/)).toBeInTheDocument(); + expect(screen.getByText(/"Hello World"/)).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx new file mode 100644 index 000000000..861200226 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx @@ -0,0 +1,132 @@ +import { + combineReducers, + configureStore, + createStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import fileSlice, { addOrUpdateFile } from 'preview/store/file.slice'; +import Sidebar from 'preview/route/digitaltwins/editor/Sidebar'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import * as React from 'react'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; + +describe('Sidebar', () => { + const setFileNameMock = jest.fn(); + const setFileContentMock = jest.fn(); + const setFileTypeMock = jest.fn(); + + let store: ReturnType; + let digitalTwin: DigitalTwin; + + const setupDigitalTwin = (assetName: string) => { + digitalTwin = new DigitalTwin(assetName, mockGitlabInstance); + digitalTwin.descriptionFiles = ['file1.md', 'file2.md']; + digitalTwin.configFiles = ['config1.json', 'config2.json']; + digitalTwin.lifecycleFiles = ['lifecycle1.txt', 'lifecycle2.txt']; + digitalTwin.getDescriptionFiles = jest + .fn() + .mockResolvedValue(digitalTwin.descriptionFiles); + digitalTwin.getConfigFiles = jest + .fn() + .mockResolvedValue(digitalTwin.configFiles); + digitalTwin.getLifecycleFiles = jest + .fn() + .mockResolvedValue(digitalTwin.lifecycleFiles); + }; + + const clickFileType = async (type: string) => { + const node = screen.getByText(type); + fireEvent.click(node); + + await waitFor(() => { + expect(screen.queryByRole('circular-progress')).not.toBeInTheDocument(); + }); + }; + + const testFileClick = async ( + type: string, + expectedFileNames: string[], + mockContent: string, + ) => { + await clickFileType(type); + digitalTwin.getFileContent = jest.fn().mockResolvedValue(mockContent); + + await waitFor(() => { + expectedFileNames.forEach((fileName) => { + expect(screen.getByText(fileName)).toBeInTheDocument(); + }); + }); + + const fileToClick = screen.getByText(expectedFileNames[0]); + fireEvent.click(fileToClick); + + await waitFor(() => { + expect(setFileNameMock).toHaveBeenCalledWith(expectedFileNames[0]); + }); + }; + + const performFileTests = async () => { + await testFileClick( + 'Description', + ['file1.md', 'file2.md'], + 'file 1 content', + ); + await testFileClick( + 'Configuration', + ['config1.json', 'config2.json'], + 'config 1 content', + ); + await testFileClick( + 'Lifecycle', + ['lifecycle1.txt', 'lifecycle2.txt'], + 'lifecycle 1 content', + ); + }; + + beforeEach(async () => { + await React.act(async () => { + store = configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + files: fileSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), + }); + + const files = [ + { name: 'Asset 1', content: 'content1', isModified: false }, + ]; + store.dispatch(addOrUpdateFile(files[0])); + + setupDigitalTwin('Asset 1'); + + store.dispatch(setDigitalTwin({ assetName: 'Asset 1', digitalTwin })); + + render( + + + , + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleFileClick when a file type is clicked', async () => { + await performFileTests(); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx new file mode 100644 index 000000000..aa8aca304 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx @@ -0,0 +1,144 @@ +import AssetBoard from 'preview/components/asset/AssetBoard'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import * as React from 'react'; +import { addOrUpdateFile } from 'preview/store/file.slice'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { showSnackbar } from 'preview/store/snackbar.slice'; +import * as ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; +import setupStore from './utils'; + +jest.useFakeTimers(); + +describe('ReconfigureDialog', () => { + let storeConfig: ReturnType; + + beforeEach(() => { + storeConfig = setupStore(); + + React.act(() => { + render( + + + , + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('closes the ConfirmationDialog with No', async () => { + const reconfigureButton = screen.getByRole('button', { + name: /Reconfigure/i, + }); + React.act(() => { + reconfigureButton.click(); + }); + + const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); + React.act(() => { + cancelButton.click(); + }); + + const noButton = await screen.findByRole('button', { name: /No/i }); + React.act(() => { + noButton.click(); + }); + + await waitFor(() => { + expect(screen.queryByText('Are you sure you want to cancel?')).toBeNull(); + expect(screen.queryByText('Editor')).toBeInTheDocument(); + }); + }); + + it('closes the Confirmation dialog with Yes', async () => { + const reconfigureButton = screen.getByRole('button', { + name: /Reconfigure/i, + }); + React.act(() => { + reconfigureButton.click(); + }); + + const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); + React.act(() => { + cancelButton.click(); + }); + + const yesButton = await screen.findByRole('button', { name: /Yes/i }); + React.act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(screen.queryByText('Editor')).toBeNull(); + }); + }); + + it('updates the description when description.md is modified', async () => { + jest.spyOn(DigitalTwin.prototype, 'updateFileContent').mockResolvedValue(); + const modifiedFile = { + name: 'description.md', + content: 'New content', + isModified: true, + }; + + React.act(() => { + storeConfig.dispatch(addOrUpdateFile(modifiedFile)); + }); + + const reconfigureButton = screen.getByRole('button', { + name: /Reconfigure/i, + }); + React.act(() => { + reconfigureButton.click(); + }); + + const saveButton = await screen.findByRole('button', { name: /Save/i }); + React.act(() => { + saveButton.click(); + }); + + const yesButton = await screen.findByRole('button', { name: /Yes/i }); + React.act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(DigitalTwin.prototype.updateFileContent).toHaveBeenCalledWith( + 'description.md', + 'New content', + ); + const state = storeConfig.getState(); + expect(state.digitalTwin['Asset 1'].description).toBe('New content'); + }); + }); + + it('calls handleCloseReconfigureDialog when the dialog is closed', () => { + const setShowDialog = jest.fn(); + ReconfigureDialog.handleCloseReconfigureDialog(setShowDialog); + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + + it('should dispatch error message when updateFileContent throws an error', async () => { + const file = { name: 'test.md', content: 'Content', isModified: true }; + const digitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + const dispatch = jest.fn(); + + jest + .spyOn(DigitalTwin.prototype, 'updateFileContent') + .mockRejectedValue('Mocked error'); + + await ReconfigureDialog.handleFileUpdate(file, digitalTwin, dispatch); + + expect(dispatch).toHaveBeenCalledWith( + showSnackbar({ + message: 'Error updating file test.md: Mocked error', + severity: 'error', + }), + ); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx new file mode 100644 index 000000000..b6a0edf96 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; +import { Provider } from 'react-redux'; +import AssetBoard from 'preview/components/asset/AssetBoard'; +import setupStore from './utils'; + +jest.useFakeTimers(); + +describe('DeleteDialog', () => { + let storeDelete: ReturnType; + + beforeEach(() => { + storeDelete = setupStore(); + + React.act(() => { + render( + + + , + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('opens the DeleteDialog when the Delete button is clicked', async () => { + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + React.act(() => { + deleteButton.click(); + }); + + await waitFor(() => { + const deleteDialog = screen.getByText('This step is irreversible', { + exact: false, + }); + expect(deleteDialog).toBeInTheDocument(); + }); + }); + + it('closes the DeleteDialog when the Cancel button is clicked', async () => { + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + React.act(() => { + deleteButton.click(); + }); + + const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); + + React.act(() => { + cancelButton.click(); + }); + + await waitFor(() => { + expect( + screen.queryByText('This step is irreversible', { exact: false }), + ).toBeNull(); + }); + }); + + it('deletes the asset when the Yes button is clicked', async () => { + jest + .spyOn(DigitalTwin.prototype, 'delete') + .mockResolvedValue('Asset 1 deleted successfully'); + + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + React.act(() => { + deleteButton.click(); + }); + + const yesButton = await screen.findByRole('button', { name: /Yes/i }); + + React.act(() => { + yesButton.click(); + }); + + await waitFor(() => { + const state = storeDelete.getState(); + expect(state.snackbar.open).toBe(true); + expect(state.snackbar.message).toBe('Asset 1 deleted successfully'); + expect(state.snackbar.severity).toBe('success'); + }); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx new file mode 100644 index 000000000..ab2ff27e7 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx @@ -0,0 +1,71 @@ +import AssetBoard from 'preview/components/asset/AssetBoard'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import * as React from 'react'; +import setupStore from './utils'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.useFakeTimers(); + +describe('DetailsDialog', () => { + let storeDetails: ReturnType; + + beforeEach(() => { + storeDetails = setupStore(); + + React.act(() => { + render( + + + , + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the AssetCardManage with Details button', async () => { + const detailsButton = screen.getByRole('button', { name: /Details/i }); + expect(detailsButton).toBeInTheDocument(); + }); + + it('opens the DetailsDialog when the Details button is clicked', async () => { + const detailsButton = screen.getByRole('button', { name: /Details/i }); + React.act(() => { + detailsButton.click(); + }); + + await waitFor(() => { + const detailsDialog = screen.getByText( + /There is no README\.md file in the Asset 1 GitLab folder/, + ); + expect(detailsDialog).toBeInTheDocument(); + }); + }); + + it('closes the DetailsDialog when the Close button is clicked', async () => { + const detailsButton = screen.getByRole('button', { name: /Details/i }); + React.act(() => { + detailsButton.click(); + }); + + const closeButton = await screen.findByRole('button', { name: /Close/i }); + + React.act(() => { + closeButton.click(); + }); + + await waitFor(() => { + expect( + screen.queryByText( + 'There is no README.md file in the Asset 1 GitLab folder', + ), + ).toBeNull(); + }); + }); +}); diff --git a/client/test/preview/integration/route/digitaltwins/manage/utils.ts b/client/test/preview/integration/route/digitaltwins/manage/utils.ts new file mode 100644 index 000000000..202e58f45 --- /dev/null +++ b/client/test/preview/integration/route/digitaltwins/manage/utils.ts @@ -0,0 +1,47 @@ +import { + combineReducers, + configureStore, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import { Asset } from 'preview/components/asset/Asset'; +import fileSlice, { + FileState, + addOrUpdateFile, +} from 'preview/store/file.slice'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarReducer from 'preview/store/snackbar.slice'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; + +const setupStore = () => { + const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const files: FileState[] = [ + { name: 'Asset 1', content: 'content1', isModified: false }, + ]; + + const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarReducer, + files: fileSlice, + }), + middleware: getDefaultMiddleware({ + serializableCheck: false, + }), + }); + + const digitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); + digitalTwin.descriptionFiles = ['description.md']; + + store.dispatch(setAssets(preSetItems)); + store.dispatch(setDigitalTwin({ assetName: 'Asset 1', digitalTwin })); + store.dispatch(addOrUpdateFile(files[0])); + + return store; +}; + +export default setupStore; diff --git a/client/test/preview/unit/components/asset/AssetBoard.test.tsx b/client/test/preview/unit/components/asset/AssetBoard.test.tsx index 6e8f89bee..ea3d607c2 100644 --- a/client/test/preview/unit/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetBoard.test.tsx @@ -4,14 +4,14 @@ import { Provider, useDispatch, useSelector } from 'react-redux'; import AssetBoard from 'preview/components/asset/AssetBoard'; import store from 'store/store'; -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), - useDispatch: jest.fn(), -})); - jest.mock('preview/components/asset/AssetCard', () => ({ - default: () =>
Asset Card
, + AssetCardManage: ({ onDelete }: { onDelete: () => void }) => ( +
+ Asset Card Manage + +
+ ), + AssetCardExecute: () =>
Asset Card Execute
, })); jest.mock('preview/store/assets.slice', () => ({ @@ -21,10 +21,10 @@ jest.mock('preview/store/assets.slice', () => ({ describe('AssetBoard', () => { const mockDispatch = jest.fn(); - const renderAssetBoard = (error: null | string) => + const renderAssetBoard = (tab: string, error: null | string) => render( - + , ); @@ -46,15 +46,30 @@ describe('AssetBoard', () => { jest.clearAllMocks(); }); - it('renders AssetBoard with assets', () => { - renderAssetBoard(null); + it('renders AssetBoard with Manage Card', () => { + renderAssetBoard('Manage', null); + + expect(screen.getByText('Asset Card Manage')).toBeInTheDocument(); + }); + + it('renders AssetBoard with Execute Card', () => { + renderAssetBoard('Execute', null); - expect(screen.getByText('Asset Card')).toBeInTheDocument(); + expect(screen.getByText('Asset Card Execute')).toBeInTheDocument(); }); it('renders error message when error is present', () => { - renderAssetBoard('An error occurred'); + renderAssetBoard('Execute', 'An error occurred'); expect(screen.getByText('An error occurred')).toBeInTheDocument(); }); + + it('dispatches deleteAsset action when onDelete is called', () => { + renderAssetBoard('Manage', null); + + const deleteButton = screen.getByText('Delete'); + deleteButton.click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); }); diff --git a/client/test/preview/unit/components/asset/AssetCard.test.tsx b/client/test/preview/unit/components/asset/AssetCard.test.tsx index e80b46b39..cf9059401 100644 --- a/client/test/preview/unit/components/asset/AssetCard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetCard.test.tsx @@ -1,5 +1,8 @@ import { render, screen } from '@testing-library/react'; -import AssetCardExecute from 'preview/components/asset/AssetCard'; +import { + AssetCardManage, + AssetCardExecute, +} from 'preview/components/asset/AssetCard'; import * as React from 'react'; import { Provider, useSelector } from 'react-redux'; import store from 'store/store'; @@ -20,13 +23,57 @@ jest.mock('preview/route/digitaltwins/execute/LogDialog', () => ({ default: () =>
, })); -describe('AssetCardExecute', () => { - const asset = { - name: 'asset', - description: 'Asset description', - path: 'path', - }; +jest.mock('preview/route/digitaltwins/manage/DetailsDialog', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('preview/route/digitaltwins/manage/ReconfigureDialog', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('preview/route/digitaltwins/manage/DeleteDialog', () => ({ + __esModule: true, + default: () =>
, +})); + +const asset = { + name: 'asset', + description: 'Asset description', + path: 'path', +}; + +describe('AssetCardManage', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders AssetCardManage with digital twin description', () => { + (useSelector as jest.Mock).mockImplementation((selector) => + selector({ + digitalTwin: { + [asset.name]: { description: 'Digital Twin description' }, + }, + }), + ); + render( + + {}} /> + , + ); + + expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); + expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByTestId('custom-snackbar')).toBeInTheDocument(); + expect(screen.getByTestId('details-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('reconfigure-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('delete-dialog')).toBeInTheDocument(); + }); +}); + +describe('AssetCardExecute', () => { afterEach(() => { jest.clearAllMocks(); }); diff --git a/client/test/preview/unit/components/asset/ConfigButton.test.tsx b/client/test/preview/unit/components/asset/ConfigButton.test.tsx new file mode 100644 index 000000000..11ec8b8be --- /dev/null +++ b/client/test/preview/unit/components/asset/ConfigButton.test.tsx @@ -0,0 +1,48 @@ +import { screen, render, fireEvent } from '@testing-library/react'; +import ReconfigureButton from 'preview/components/asset/ReconfigureButton'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import store from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +describe('ReconfigureButton', () => { + const renderReconfigureButton = (setShowReconfigure: jest.Mock = jest.fn()) => + render( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Reconfigure button', () => { + renderReconfigureButton(); + expect( + screen.getByRole('button', { name: /Reconfigure/i }), + ).toBeInTheDocument(); + }); + + it('toggles setShowReconfigure value correctly', () => { + let toggleValue = false; + const mockSetShowReconfigure = jest.fn((callback) => { + toggleValue = callback(toggleValue); + }); + + renderReconfigureButton(mockSetShowReconfigure); + + const reconfigureButton = screen.getByRole('button', { + name: /Reconfigure/i, + }); + + fireEvent.click(reconfigureButton); + expect(toggleValue).toBe(true); + + fireEvent.click(reconfigureButton); + expect(toggleValue).toBe(false); + }); +}); diff --git a/client/test/preview/unit/components/asset/DeleteButton.test.tsx b/client/test/preview/unit/components/asset/DeleteButton.test.tsx new file mode 100644 index 000000000..0a29d2646 --- /dev/null +++ b/client/test/preview/unit/components/asset/DeleteButton.test.tsx @@ -0,0 +1,37 @@ +import DeleteButton from 'preview/components/asset/DeleteButton'; +import { Provider } from 'react-redux'; +import store from 'store/store'; +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +describe('DeleteButton', () => { + const renderDeleteButton = (setShowDelete: jest.Mock = jest.fn()) => + render( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Delete button', () => { + renderDeleteButton(); + expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument(); + }); + + it('handles button click', () => { + const mockSetShowDelete = jest.fn(); + renderDeleteButton(mockSetShowDelete); + + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + fireEvent.click(deleteButton); + + expect(mockSetShowDelete).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/components/asset/DetailsButton.test.tsx b/client/test/preview/unit/components/asset/DetailsButton.test.tsx new file mode 100644 index 000000000..8f2c3ee65 --- /dev/null +++ b/client/test/preview/unit/components/asset/DetailsButton.test.tsx @@ -0,0 +1,52 @@ +import DetailsButton from 'preview/components/asset/DetailsButton'; +import { Provider } from 'react-redux'; +import store from 'store/store'; +import * as React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import * as redux from 'react-redux'; +import { Dispatch } from 'react'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('DetailsButton', () => { + const renderDetailsButton = ( + assetName: string, + setShowDetails: Dispatch>, + ) => + render( + + + , + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Details button', () => { + renderDetailsButton('AssetName', jest.fn()); + expect( + screen.getByRole('button', { name: /Details/i }), + ).toBeInTheDocument(); + }); + + it('handles button click and shows details', async () => { + const mockSetShowDetails = jest.fn(); + + (redux.useSelector as jest.Mock).mockReturnValue({ + getFullDescription: jest.fn().mockResolvedValue('Mocked description'), + }); + + renderDetailsButton('AssetName', mockSetShowDetails); + + const detailsButton = screen.getByRole('button', { name: /Details/i }); + fireEvent.click(detailsButton); + + await waitFor(() => { + expect(mockSetShowDetails).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/client/test/preview/unit/jest.setup.ts b/client/test/preview/unit/jest.setup.ts index 3f38356b7..1ca45f5cd 100644 --- a/client/test/preview/unit/jest.setup.ts +++ b/client/test/preview/unit/jest.setup.ts @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; import 'test/preview/__mocks__/global_mocks'; // import 'test/preview/__mocks__/unit/page_mocks'; import 'test/preview/__mocks__/unit/component_mocks'; -// import 'test/preview/__mocks__/unit/module_mocks'; +import 'test/preview/__mocks__/unit/module_mocks'; beforeEach(() => { jest.resetAllMocks(); diff --git a/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx b/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx index d17cd56aa..73d630366 100644 --- a/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/Snackbar.test.tsx @@ -5,12 +5,6 @@ import { Provider, useSelector, useDispatch } from 'react-redux'; import store from 'store/store'; import { hideSnackbar } from 'preview/store/snackbar.slice'; -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), - useDispatch: jest.fn(), -})); - jest.useFakeTimers(); describe('CustomSnackbar', () => { diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx new file mode 100644 index 000000000..191bc74ca --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import Editor from 'preview/route/digitaltwins/editor/Editor'; +import * as React from 'react'; + +jest.mock('preview/route/digitaltwins/editor/EditorTab', () => ({ + __esModule: true, + default: () =>
EditorTab
, +})); + +jest.mock('preview/route/digitaltwins/editor/PreviewTab', () => ({ + __esModule: true, + default: () =>
PreviewTab
, +})); + +jest.mock('preview/route/digitaltwins/editor/Sidebar', () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +describe('Editor', () => { + beforeEach(() => { + render(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('render Editor', () => { + expect(screen.getByText('EditorTab')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Sidebar')).toBeInTheDocument(); + }); + + it('updates active tab on tab change', () => { + const editor = screen.getByText('Editor'); + const preview = screen.getByText('Preview'); + + expect(editor).toHaveAttribute('aria-selected', 'true'); + expect(preview).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(preview); + + expect(editor).toHaveAttribute('aria-selected', 'false'); + expect(preview).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx new file mode 100644 index 000000000..af8752a9a --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import EditorTab from 'preview/route/digitaltwins/editor/EditorTab'; +import { addOrUpdateFile } from 'preview/store/file.slice'; + +jest.mock('preview/store/file.slice', () => ({ + addOrUpdateFile: jest.fn(), +})); + +describe('EditorTab', () => { + const mockSetFileContent = jest.fn(); + const mockDispatch = jest.fn(); + + beforeEach(() => { + (jest.requireMock('react-redux').useDispatch as jest.Mock).mockReturnValue( + mockDispatch, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders EditorTab', async () => { + waitFor(async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('fileName')).toBeInTheDocument(); + expect(screen.getByText('fileContent')).toBeInTheDocument(); + }); + }); + }); + + it('calls handleEditorChange via onChange correctly', async () => { + waitFor(async () => { + render( + , + ); + + const newValue = 'New content'; + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: newValue }, + }); + + await waitFor(() => { + expect(mockSetFileContent).toHaveBeenCalledWith(newValue); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateFile({ + name: 'fileName', + content: newValue, + isModified: true, + }), + ); + }); + }); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/PreviewTab.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/PreviewTab.test.tsx new file mode 100644 index 000000000..3888fa1e8 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/PreviewTab.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import PreviewTab from 'preview/route/digitaltwins/editor/PreviewTab'; +import * as React from 'react'; + +describe('PreviewTab', () => { + const fileTypes = ['md', 'json', 'yaml', 'yml', 'bash']; + + fileTypes.forEach((fileType) => { + it(`renders PreviewTab with ${fileType} content`, () => { + render(); + expect(screen.getByText('fileContent')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx new file mode 100644 index 000000000..d050097fc --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx @@ -0,0 +1,175 @@ +import { + render, + waitFor, + screen, + act, + fireEvent, +} from '@testing-library/react'; +import Sidebar, { + handleFileClick, +} from 'preview/route/digitaltwins/editor/Sidebar'; +import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; +import { FileState } from 'preview/store/file.slice'; +import * as React from 'react'; +import { Provider, useSelector } from 'react-redux'; +import store, { RootState } from 'store/store'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('Sidebar', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + + const clickFile = async (fileType: string, expectedFileName: string) => { + const fileNode = screen.getByText(fileType); + fireEvent.click(fileNode); + + await waitFor(() => { + expect(screen.getByText(expectedFileName)).toBeInTheDocument(); + }); + + const file = screen.getByText(expectedFileName); + fireEvent.click(file); + }; + + beforeEach(async () => { + (useSelector as jest.Mock).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectDigitalTwinByName('mockedDTName')) { + return mockDigitalTwin; + } + if (selector.toString().includes('state.files')) { + return []; + } + return mockDigitalTwin; + }, + ); + + await act(async () => { + render( + + + , + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders Sidebar', async () => { + await waitFor(() => { + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Configuration')).toBeInTheDocument(); + }); + }); + + it('should update file state if the file is modified', async () => { + const modifiedFiles: FileState[] = [ + { name: 'testFile.md', content: 'modified content', isModified: true }, + ]; + + await act(async () => { + await handleFileClick( + 'testFile.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + }); + + expect(setFileName).toHaveBeenCalledWith('testFile.md'); + expect(setFileContent).toHaveBeenCalledWith('modified content'); + expect(setFileType).toHaveBeenCalledWith('md'); + expect(mockDigitalTwin.getFileContent).not.toHaveBeenCalled(); + }); + + it('should fetch and update file state if the file is not modified', async () => { + const modifiedFiles: FileState[] = []; + mockDigitalTwin.getFileContent = jest + .fn() + .mockResolvedValue('fetched content'); + + await act(async () => { + await handleFileClick( + 'testFile.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + }); + + expect(mockDigitalTwin.getFileContent).toHaveBeenCalledWith('testFile.md'); + expect(setFileName).toHaveBeenCalledWith('testFile.md'); + expect(setFileContent).toHaveBeenCalledWith('fetched content'); + expect(setFileType).toHaveBeenCalledWith('md'); + }); + + it('should set error message if fetching file content fails', async () => { + const modifiedFiles: FileState[] = []; + mockDigitalTwin.getFileContent = jest.fn().mockResolvedValue(null); + + await act(async () => { + await handleFileClick( + 'testFile.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + modifiedFiles, + ); + }); + + expect(mockDigitalTwin.getFileContent).toHaveBeenCalledWith('testFile.md'); + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching testFile.md content', + ); + }); + + it('calls handleFileClick when a description file is clicked', async () => { + await clickFile('Description', 'descriptionFile'); + }); + + it('calls handleFileClick when a config file is clicked', async () => { + await clickFile('Configuration', 'configFile'); + }); + + it('calls handleFileClick when a lifecycle file is clicked', async () => { + await clickFile('Lifecycle', 'lifecycleFile'); + }); + + it('call setFileContent with error message if file content is null', async () => { + mockDigitalTwin.getFileContent = jest.fn().mockResolvedValue(null); + + await act(async () => { + await handleFileClick( + 'testFile.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + [], + ); + }); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching testFile.md content', + ); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx new file mode 100644 index 000000000..b1cd90cc2 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx @@ -0,0 +1,208 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import ReconfigureDialog, * as Reconfigure from 'preview/route/digitaltwins/manage/ReconfigureDialog'; +import * as React from 'react'; +import { Provider, useDispatch, useSelector } from 'react-redux'; +import store, { RootState } from 'store/store'; + +import { showSnackbar } from 'preview/store/snackbar.slice'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; + +jest.mock('preview/store/file.slice', () => ({ + ...jest.requireActual('preview/store/file.slice'), + saveAllFiles: jest.fn().mockResolvedValue(Promise.resolve()), +})); + +jest.mock('preview/store/digitalTwin.slice', () => ({ + ...jest.requireActual('preview/store/digitalTwin.slice'), + updateDescription: jest.fn(), +})); + +jest.mock('preview/store/snackbar.slice', () => ({ + ...jest.requireActual('preview/store/snackbar.slice'), + showSnackbar: jest.fn(), +})); + +jest.mock('preview/route/digitaltwins/editor/Sidebar', () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +jest.mock('preview/util/gitlabDigitalTwin', () => ({ + formatName: jest.fn().mockReturnValue('TestDigitalTwin'), +})); + +describe('ReconfigureDialog', () => { + const setShowDialog = jest.fn(); + const name = 'TestDigitalTwin'; + + beforeEach(() => { + const dispatch = jest.fn(); + (useDispatch as jest.Mock).mockReturnValue(dispatch); + + (useSelector as jest.Mock).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectDigitalTwinByName('mockedDTName')) { + return mockDigitalTwin; + } + if (selector.toString().includes('state.files')) { + return [ + { + name: 'description.md', + content: 'Updated description', + isModified: true, + }, + { + name: 'lifecycle.md', + content: 'Updated lifecycle', + isModified: true, + }, + ]; + } + return mockDigitalTwin; + }, + ); + + render( + + + , + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('renders the Reconfigure dialog', () => { + expect(screen.getByText('Reconfigure')).toBeInTheDocument(); + }); + + it('handles close dialog', async () => { + const closeButton = screen.getByRole('button', { name: /Cancel/i }); + + act(() => { + fireEvent.click(closeButton); + }); + + expect( + screen.getByText( + 'Are you sure you want to cancel? Changes will not be applied.', + ), + ).toBeInTheDocument(); + + const yesButton = screen.getByRole('button', { name: /Yes/i }); + act(() => { + fireEvent.click(yesButton); + }); + + await waitFor(() => { + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + }); + + it('calls handleCloseLog when the close function is called', async () => { + await act(async () => { + await Reconfigure.handleCloseReconfigureDialog(setShowDialog); + }); + + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + + it('handles save dialog', async () => { + const saveButton = screen.getByRole('button', { name: /Save/i }); + + act(() => { + saveButton.click(); + }); + + expect( + screen.getByText('Are you sure you want to apply the changes?'), + ).toBeInTheDocument(); + + const yesButton = screen.getByRole('button', { name: /Yes/i }); + + act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + }); + + it('should update file content and dispatch updateDescription for description.md', async () => { + const dispatch = jest.fn(); + const descriptionFile = { + name: 'description.md', + content: 'Updated description', + isModified: true, + }; + + mockDigitalTwin.updateFileContent = jest + .fn() + .mockResolvedValue(Promise.resolve()); + + await Reconfigure.handleFileUpdate( + descriptionFile, + mockDigitalTwin, + dispatch, + ); + }); + + it('shows error snackbar on file update failure', async () => { + const dispatch = useDispatch(); + const saveButton = screen.getByRole('button', { name: /Save/i }); + + mockDigitalTwin.updateFileContent = jest + .fn() + .mockRejectedValueOnce(new Error('Error updating file')); + + act(() => { + saveButton.click(); + }); + + const yesButton = screen.getByRole('button', { name: /Yes/i }); + act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith( + showSnackbar({ + message: 'Error updating file description.md: Error updating file', + severity: 'error', + }), + ); + }); + }); + + it('saves changes and calls handleFileUpdate for each modified file', async () => { + const handleFileUpdateSpy = jest.spyOn(Reconfigure, 'handleFileUpdate'); + + const saveButton = screen.getByRole('button', { name: /Save/i }); + act(() => { + saveButton.click(); + }); + + const yesButton = screen.getByRole('button', { name: /Yes/i }); + act(() => { + yesButton.click(); + }); + + await waitFor(() => { + expect(handleFileUpdateSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/DeleteDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/DeleteDialog.test.tsx new file mode 100644 index 000000000..80ece9ea8 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/manage/DeleteDialog.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import DeleteDialog from 'preview/route/digitaltwins/manage/DeleteDialog'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider, useSelector } from 'react-redux'; +import store from 'store/store'; +import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('preview/util/gitlabDigitalTwin', () => ({ + DigitalTwin: jest.fn().mockImplementation(() => mockDigitalTwin), + formatName: jest.fn(), +})); + +describe('DeleteDialog', () => { + const showDialog = true; + const name = 'testName'; + const setShowDialog = jest.fn(); + const onDelete = jest.fn(); + + it('renders the DeleteDialog', () => { + render( + + + , + ); + expect(screen.getByText(/This step is irreversible/i)).toBeInTheDocument(); + }); + + it('handles close dialog', async () => { + render( + + + , + ); + const closeButton = screen.getByRole('button', { name: /Cancel/i }); + closeButton.click(); + expect(setShowDialog).toHaveBeenCalled(); + }); + + it('handles delete button click', async () => { + (useSelector as jest.Mock).mockReturnValue({ + delete: jest.fn().mockResolvedValue('Deleted successfully'), + }); + + render( + + + , + ); + + const deleteButton = screen.getByRole('button', { name: /Yes/i }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalled(); + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + }); + + it('handles delete button click and shows error message', async () => { + (useSelector as jest.Mock).mockReturnValue({ + delete: jest.fn().mockResolvedValue('Error: deletion failed'), + }); + + render( + + + , + ); + + const deleteButton = screen.getByRole('button', { name: /Yes/i }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalled(); + expect(setShowDialog).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx new file mode 100644 index 000000000..0d8182eda --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import DetailsDialog from 'preview/route/digitaltwins/manage/DetailsDialog'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; + +describe('DetailsDialog', () => { + const setShowDialog = jest.fn(); + + beforeEach(() => { + (useSelector as jest.Mock).mockImplementation(() => ({ + fullDescription: 'fullDescription', + })); + }); + + it('renders DetailsDialog', () => { + render( + , + ); + + expect(screen.getByText('fullDescription')).toBeInTheDocument(); + }); + + it('closes the dialog when the "Close" button is clicked', () => { + render( + , + ); + + screen.getByText('Close').click(); + + expect(setShowDialog).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/test/preview/unit/util/Store.test.ts b/client/test/preview/unit/util/Store.test.ts index 5038a77e4..a8ee76d22 100644 --- a/client/test/preview/unit/util/Store.test.ts +++ b/client/test/preview/unit/util/Store.test.ts @@ -1,9 +1,13 @@ -import assetsSlice, { setAssets } from 'preview/store/assets.slice'; +import assetsSlice, { + deleteAsset, + setAssets, +} from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, setJobLogs, setPipelineCompleted, setPipelineLoading, + updateDescription, } from 'preview/store/digitalTwin.slice'; import DigitalTwin from 'preview/util/gitlabDigitalTwin'; import GitlabInstance from 'preview/util/gitlab'; @@ -14,6 +18,10 @@ import snackbarSlice, { showSnackbar, } from 'preview/store/snackbar.slice'; import { AlertColor } from '@mui/material'; +import fileSlice, { + addOrUpdateFile, + saveAllFiles, +} from 'preview/store/file.slice'; describe('reducers', () => { let initialState: { @@ -28,6 +36,11 @@ describe('reducers', () => { message: string; severity: AlertColor; }; + files: { + name: string; + content: string; + isModified: boolean; + }[]; }; beforeEach(() => { @@ -39,24 +52,37 @@ describe('reducers', () => { message: '', severity: 'info', }, + files: [], }; }); describe('assets reducer', () => { - it('should handle setAssets', () => { - const asset1 = { - name: 'asset1', - description: 'description', - path: 'path', - }; + const asset1 = { + name: 'asset1', + description: 'description', + path: 'path', + }; + it('should handle setAssets', () => { const newState = assetsSlice(initialState.assets, setAssets([asset1])); expect(newState.items).toEqual([asset1]); }); + + it('should handle deleteAsset', () => { + initialState.assets.items = [asset1]; + const newState = assetsSlice(initialState.assets, deleteAsset('path')); + + expect(newState.items).toEqual([]); + }); }); describe('digitalTwin reducer', () => { + const digitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + it('digitalTwinReducer should return the initial digitalTwin state when an unknown action type is passed with an undefined state', () => { expect(digitalTwinReducer(undefined, { type: 'unknown' })).toEqual( initialState.digitalTwin, @@ -64,10 +90,6 @@ describe('reducers', () => { }); it('should handle setDigitalTwin', () => { - const digitalTwin = new DigitalTwin( - 'asset1', - new GitlabInstance('user1', 'authority', 'token1'), - ); const newState = digitalTwinReducer( initialState.digitalTwin, setDigitalTwin({ assetName: 'asset1', digitalTwin }), @@ -77,10 +99,6 @@ describe('reducers', () => { it('should handle setJobLogs', () => { const jobLogs: JobLog[] = [{ jobName: 'job1', log: 'log' }]; - const digitalTwin = new DigitalTwin( - 'asset1', - new GitlabInstance('user1', 'authority', 'token1'), - ); digitalTwin.jobLogs = jobLogs; initialState.digitalTwin.asset1 = digitalTwin; const newState = digitalTwinReducer( @@ -91,10 +109,6 @@ describe('reducers', () => { }); it('should handle setPipelineCompleted', () => { - const digitalTwin = new DigitalTwin( - 'asset1', - new GitlabInstance('user1', 'authority', 'token1'), - ); initialState.digitalTwin.asset1 = digitalTwin; const newState = digitalTwinReducer( initialState.digitalTwin, @@ -104,10 +118,6 @@ describe('reducers', () => { }); it('should handle setPipelineLoading', () => { - const digitalTwin = new DigitalTwin( - 'asset1', - new GitlabInstance('user1', 'authority', 'token1'), - ); initialState.digitalTwin.asset1 = digitalTwin; const newState = digitalTwinReducer( initialState.digitalTwin, @@ -115,6 +125,16 @@ describe('reducers', () => { ); expect(newState.asset1.pipelineLoading).toBe(true); }); + + it('should handle updateDescription', () => { + initialState.digitalTwin.asset1 = digitalTwin; + const description = 'new description'; + const newState = digitalTwinReducer( + initialState.digitalTwin, + updateDescription({ assetName: 'asset1', description }), + ); + expect(newState.asset1.description).toBe(description); + }); }); describe('snackbar reducer', () => { @@ -140,4 +160,43 @@ describe('reducers', () => { expect(newState.severity).toBe('info'); }); }); + + describe('file reducer', () => { + const file1 = { + name: 'fileName', + content: 'fileContent', + isModified: true, + }; + + const file2 = { + name: 'fileName2', + content: 'fileContent2', + isModified: true, + }; + + it('should handle addOrUpdateFile', () => { + const newState = fileSlice(initialState.files, addOrUpdateFile(file1)); + expect(newState).toEqual([file1]); + }); + + it('should handle addOrUpdateFile when file already exists', () => { + const file1Updated = { + name: 'fileName', + content: 'newContent', + isModified: true, + }; + initialState.files = [file1]; + const newState = fileSlice( + initialState.files, + addOrUpdateFile(file1Updated), + ); + expect(newState).toEqual([file1Updated]); + }); + + it('should handle saveAllFiles', () => { + initialState.files = [file1, file2]; + const newState = fileSlice(initialState.files, saveAllFiles()); + expect(newState).toEqual([]); + }); + }); }); diff --git a/client/test/preview/unit/util/gitlab.test.ts b/client/test/preview/unit/util/gitlab.test.ts index bea4f33ea..b66d5c43d 100644 --- a/client/test/preview/unit/util/gitlab.test.ts +++ b/client/test/preview/unit/util/gitlab.test.ts @@ -82,24 +82,6 @@ describe('GitlabInstance', () => { expect(mockApi.PipelineTriggerTokens.all).toHaveBeenCalledWith(1); }); - it('should handle error fetching DT description', async () => { - const dtName = 'test-dt'; - const readmePath = `digital_twins/${dtName}/description.md`; - - mockApi.RepositoryFiles.show.mockRejectedValue( - new Error('Failed to fetch'), - ); - - await expect(gitlab.getDTDescription(dtName)).rejects.toThrow( - 'Failed to fetch', - ); - expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( - gitlab.projectId!, - readmePath, - 'main', - ); - }); - it('should fetch DT subfolders successfully', async () => { const projectId = 1; const files = [ @@ -108,12 +90,6 @@ describe('GitlabInstance', () => { { name: 'file1', path: 'digital_twins/file1', type: 'blob' }, ]; - gitlab.getDTDescription = jest - .fn() - .mockImplementation((name: string) => - Promise.resolve(`Description for ${name}`), - ); - mockApi.Repositories.allRepositoryTrees.mockResolvedValue(files); const subfolders = await gitlab.getDTSubfolders(projectId); @@ -123,12 +99,10 @@ describe('GitlabInstance', () => { { name: 'subfolder1', path: 'digital_twins/subfolder1', - description: 'Description for subfolder1', }, { name: 'subfolder2', path: 'digital_twins/subfolder2', - description: 'Description for subfolder2', }, ]); expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith( @@ -199,22 +173,4 @@ describe('GitlabInstance', () => { expect(result).toBe(status); expect(mockApi.Pipelines.show).toHaveBeenCalledWith(projectId, pipelineId); }); - - it('should fetch DT description successfully and decode content', async () => { - const dtName = 'test-dt'; - const readmePath = `digital_twins/${dtName}/description.md`; - const encodedContent = btoa('Description content'); - const mockFileData = { content: encodedContent }; - - mockApi.RepositoryFiles.show.mockResolvedValue(mockFileData); - - const description = await gitlab.getDTDescription(dtName); - - expect(description).toBe('Description content'); - expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( - gitlab.projectId!, - readmePath, - 'main', - ); - }); }); diff --git a/client/test/preview/unit/util/gitlabDigitalTwin.test.ts b/client/test/preview/unit/util/gitlabDigitalTwin.test.ts index 38658a776..d27cf00a6 100644 --- a/client/test/preview/unit/util/gitlabDigitalTwin.test.ts +++ b/client/test/preview/unit/util/gitlabDigitalTwin.test.ts @@ -5,6 +5,10 @@ const mockApi = { RepositoryFiles: { show: jest.fn(), remove: jest.fn(), + edit: jest.fn(), + }, + Repositories: { + allRepositoryTrees: jest.fn(), }, PipelineTriggerTokens: { trigger: jest.fn(), @@ -31,15 +35,56 @@ describe('DigitalTwin', () => { dt = new DigitalTwin('test-DTName', mockGitlabInstance); }); - it('should return full description if projectId exists', async () => { - const mockContent = btoa('Test README content'); + it('should get description', async () => { + (mockApi.RepositoryFiles.show as jest.Mock).mockResolvedValue({ + content: btoa('Test description content'), + }); + + await dt.getDescription(); + + expect(dt.description).toBe('Test description content'); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/description.md', + 'main', + ); + }); + + it('should return empty description if no description file exists', async () => { + (mockApi.RepositoryFiles.show as jest.Mock).mockRejectedValue( + new Error('File not found'), + ); + + await dt.getDescription(); + + expect(dt.description).toBe( + 'There is no description.md file in the test-DTName GitLab folder', + ); + }); + + it('should return full description with updated image URLs if projectId exists', async () => { + const mockContent = btoa( + 'Test README content with an image ![alt text](image.png)', + ); + (mockApi.RepositoryFiles.show as jest.Mock).mockResolvedValue({ content: mockContent, }); + Object.defineProperty(window, 'sessionStorage', { + value: { + getItem: jest.fn(() => 'testUser'), + setItem: jest.fn(), + }, + writable: true, + }); + await dt.getFullDescription(); - expect(dt.fullDescription).toBe('Test README content'); + expect(dt.fullDescription).toBe( + 'Test README content with an image ![alt text](https://example.com/AUTHORITY/dtaas/testUser/-/raw/main/digital_twins/test-DTName/image.png)', + ); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( 1, 'digital_twins/test-DTName/README.md', @@ -172,4 +217,46 @@ describe('DigitalTwin', () => { expect(formatName(input)).toBe(expected); }); }); + + it('should delete the digital twin', async () => { + (mockApi.RepositoryFiles.remove as jest.Mock).mockResolvedValue({}); + + await dt.delete(); + + expect(mockApi.RepositoryFiles.remove).toHaveBeenCalled(); + }); + + it('should delete the digital twin and return success message', async () => { + (mockApi.RepositoryFiles.remove as jest.Mock).mockResolvedValue({}); + + const result = await dt.delete(); + + expect(result).toBe('test-DTName deleted successfully'); + expect(mockApi.RepositoryFiles.remove).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName', + 'main', + 'Removing test-DTName digital twin', + ); + }); + + it('should return error message when deletion fails', async () => { + (mockApi.RepositoryFiles.remove as jest.Mock).mockRejectedValue( + new Error('Delete failed'), + ); + + const result = await dt.delete(); + + expect(result).toBe('Error deleting test-DTName digital twin'); + }); + + it('should return error message when projectId is missing during deletion', async () => { + dt.gitlabInstance.projectId = null; + + const result = await dt.delete(); + + expect(result).toBe( + 'Error deleting test-DTName digital twin: no project id', + ); + }); }); diff --git a/client/test/preview/unit/util/gitlabDigitalTwinConfig.test.ts b/client/test/preview/unit/util/gitlabDigitalTwinConfig.test.ts new file mode 100644 index 000000000..d20b7db08 --- /dev/null +++ b/client/test/preview/unit/util/gitlabDigitalTwinConfig.test.ts @@ -0,0 +1,177 @@ +import GitlabInstance from 'preview/util/gitlab'; +import DigitalTwin from 'preview/util/gitlabDigitalTwin'; + +const mockApi = { + RepositoryFiles: { + show: jest.fn(), + remove: jest.fn(), + edit: jest.fn(), + }, + Repositories: { + allRepositoryTrees: jest.fn(), + }, + PipelineTriggerTokens: { + trigger: jest.fn(), + }, + Pipelines: { + cancel: jest.fn(), + }, +}; + +const mockGitlabInstance = { + api: mockApi as unknown as GitlabInstance['api'], + projectId: 1, + triggerToken: 'test-token', + logs: [] as { jobName: string; log: string }[], + getProjectId: jest.fn(), + getTriggerToken: jest.fn(), +} as unknown as GitlabInstance; + +describe('DigitalTwin', () => { + let dt: DigitalTwin; + + const mockResponse = [ + { type: 'blob', name: 'file1.md', path: 'test-path' }, + { type: 'blob', name: 'file2.json', path: 'test-path' }, + { type: 'blob', name: 'file3', path: '/lifecycle/test-path' }, + { type: 'tree', name: 'folder', path: 'test-path' }, + ]; + + const mockFetchFilesError = async ( + errorMessage: string, + fetchMethod: () => Promise, + resultArray: string[], + ) => { + (mockApi.Repositories.allRepositoryTrees as jest.Mock).mockRejectedValue( + new Error(errorMessage), + ); + await fetchMethod(); + expect(resultArray).toEqual([]); + }; + + const expectAllRepositoryTreesCalled = (recursive = true) => { + expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith(1, { + path: 'digital_twins/test-DTName', + recursive, + }); + }; + + beforeEach(() => { + mockGitlabInstance.projectId = 1; + dt = new DigitalTwin('test-DTName', mockGitlabInstance); + + (mockApi.Repositories.allRepositoryTrees as jest.Mock).mockResolvedValue( + mockResponse, + ); + }); + + it('should get description files', async () => { + await dt.getDescriptionFiles(); + + expectAllRepositoryTreesCalled(); + + expect(dt.descriptionFiles).toEqual(['file1.md']); + }); + + it('should return empty array when fetching description files fails', async () => { + await mockFetchFilesError( + 'Error fetching description files', + dt.getDescriptionFiles.bind(dt), + dt.descriptionFiles, + ); + }); + + it('should get lifecycle files', async () => { + await dt.getLifecycleFiles(); + + expectAllRepositoryTreesCalled(); + + expect(dt.lifecycleFiles).toEqual(['file3']); + }); + + it('should return empty array when fetching lifecycle files fails', async () => { + await mockFetchFilesError( + 'Error fetching lifecycle files', + dt.getLifecycleFiles.bind(dt), + dt.lifecycleFiles, + ); + }); + + it('should get config files', async () => { + await dt.getConfigFiles(); + + expectAllRepositoryTreesCalled(false); + + expect(dt.configFiles).toEqual(['file2.json']); + }); + + it('should return empty array when fetching config files fails', async () => { + await mockFetchFilesError( + 'Error fetching config files', + dt.getConfigFiles.bind(dt), + dt.configFiles, + ); + }); + + it('should get file content for a file with an extension', async () => { + const mockContent = btoa('Test file content'); + (mockApi.RepositoryFiles.show as jest.Mock).mockResolvedValue({ + content: mockContent, + }); + + const content = await dt.getFileContent('test-file.md'); + + expect(content).toBe('Test file content'); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/test-file.md', + 'main', + ); + }); + + it('should get file content for a file without an extension (lifecycle folder)', async () => { + const mockContent = btoa('Test lifecycle content'); + (mockApi.RepositoryFiles.show as jest.Mock).mockResolvedValue({ + content: mockContent, + }); + + const content = await dt.getFileContent('lifecycle-file'); + + expect(content).toBe('Test lifecycle content'); + expect(mockApi.RepositoryFiles.show).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/lifecycle/lifecycle-file', + 'main', + ); + }); + + it('should update file content for a file with an extension', async () => { + const mockEdit = jest.fn(); + mockApi.RepositoryFiles.edit = mockEdit; + + await dt.updateFileContent('test-file.md', 'Test file content'); + + expect(mockApi.RepositoryFiles.edit).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/test-file.md', + 'main', + 'Test file content', + 'Update test-file.md content', + ); + }); + + it('should update file content for a file without an extension (lifecycle folder)', async () => { + const mockEdit = jest.fn(); + mockApi.RepositoryFiles.edit = mockEdit; + + await dt.updateFileContent('lifecycle-file', 'Lifecycle file content'); + + expect(mockApi.RepositoryFiles.edit).toHaveBeenCalledWith( + 1, + 'digital_twins/test-DTName/lifecycle/lifecycle-file', + 'main', + 'Lifecycle file content', + 'Update lifecycle-file content', + ); + }); +}); diff --git a/client/tsconfig.gitlab.json b/client/tsconfig.gitlab.json deleted file mode 100644 index 66615df39..000000000 --- a/client/tsconfig.gitlab.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - "noImplicitAny": true, //raise error on any type - "allowSyntheticDefaultImports": true, //allow default imports from modules with no default export - "sourceMap": true, //generate .map files - "target": "es6", //target es6 - "lib": ["es2022", "webworker", "webworker.importscripts", "webworker.iterable", "scripthost", "es2022.array", "es2022.error", "es2022.intl", "es2022.object", "es2022.sharedmemory", "es2022.string"], - "jsx": "react", //use react - "types": ["react", "node"], //use react and node types - "module": "esnext", //use esnext modules - "moduleResolution": "node", //use node module resolution strategy node - "resolveJsonModule": true, - "experimentalDecorators": true, //allow experimental decorators for es7 - "declaration": false, //don't generate declaration '.d.ts' files - "removeComments": true, //remove comments from build - "noImplicitReturns": true, //raise error on implicit returns - "noUnusedLocals": true, //raise error on unused locals - "noUnusedParameters": false, //raise no error on unused parameters - "strict": true, //enable all strict type-checking options - "outDir": "dist", //output directory - "baseUrl": "src", //base url for imports - "typeRoots": [ - "node_modules/@types" //use node_modules/@types for type definitions - ], - "strictNullChecks": true //enable strict null checks - }, - "exclude": ["**/node_modules/*", "babel.config.cjs", "dist", "test"], - "include": [ - "./config/gitlab.json", - "./src/preview/util/gitlab*.ts", - "./test/preview/unit/util/gitlab*.test.ts" - ], - "typeRoots": [ "**/node_modules/@types" ] -} \ No newline at end of file diff --git a/client/yarn.lock b/client/yarn.lock index 38ddfabcd..d51bb4bea 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1115,6 +1115,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.6", "@babel/runtime@^7.3.1": + version "7.25.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.7.tgz#7ffb53c37a8f247c8c4d335e89cdf16a2e0d0fb6" + integrity sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.24.7", "@babel/template@^7.25.0", "@babel/template@^7.3.3": version "7.25.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.0.tgz#e733dc3134b4fede528c15bc95e89cb98c52592a" @@ -1949,6 +1956,20 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== +"@monaco-editor/loader@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558" + integrity sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg== + dependencies: + state-local "^1.0.6" + +"@monaco-editor/react@^4.6.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.6.0.tgz#bcc68671e358a21c3814566b865a54b191e24119" + integrity sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw== + dependencies: + "@monaco-editor/loader" "^1.4.0" + "@mui/core-downloads-tracker@^5.16.7": version "5.16.7" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.7.tgz#182a325a520f7ebd75de051fceabfc0314cfd004" @@ -2029,6 +2050,27 @@ prop-types "^15.8.1" react-is "^18.3.1" +"@mui/x-internals@7.18.0": + version "7.18.0" + resolved "https://registry.yarnpkg.com/@mui/x-internals/-/x-internals-7.18.0.tgz#f079968d4f7ea93e63be9faf6ba8558d6f12923b" + integrity sha512-lzCHOWIR0cAIY1bGrWSprYerahbnH5C31ql/2OWCEjcngL2NAV1M6oKI2Vp4HheqzJ822c60UyWyapvyjSzY/A== + dependencies: + "@babel/runtime" "^7.25.6" + "@mui/utils" "^5.16.6" + +"@mui/x-tree-view@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@mui/x-tree-view/-/x-tree-view-7.19.0.tgz#ff42eda290d54e839cb21f15a1df2bb4d6746f09" + integrity sha512-yjEapbEUNvgLoBzK7B9ISsMt6EdqCaSqJaRN9YghXq3u9ZIXBD1qtJUmWOhkon8gwNpL1iOnONN4PO7FuxLCRQ== + dependencies: + "@babel/runtime" "^7.25.6" + "@mui/utils" "^5.16.6" + "@mui/x-internals" "7.18.0" + "@types/react-transition-group" "^4.4.11" + clsx "^2.1.1" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -2481,6 +2523,13 @@ dependencies: "@types/node" "*" +"@types/hast@^2.0.0": + version "2.3.10" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.10.tgz#5c9d9e0b304bbb8879b857225c5ebab2d81d7643" + integrity sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw== + dependencies: + "@types/unist" "^2" + "@types/hoist-non-react-statics@*", "@types/hoist-non-react-statics@^3.3.1": version "3.3.5" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" @@ -2608,14 +2657,21 @@ dependencies: "@types/react" "*" -"@types/react-transition-group@^4.4.10": +"@types/react-syntax-highlighter@^15.5.13": + version "15.5.13" + resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz#c5baf62a3219b3bf28d39cfea55d0a49a263d1f2" + integrity sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA== + dependencies: + "@types/react" "*" + +"@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.11": version "4.4.11" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.11.tgz#d963253a611d757de01ebb241143b1017d5d63d5" integrity sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA== dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.38": +"@types/react@*": version "18.3.3" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== @@ -2623,6 +2679,19 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/react@^18.3.3": + version "18.3.11" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.11.tgz#9d530601ff843ee0d7030d4227ea4360236bd537" + integrity sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + +"@types/remarkable@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@types/remarkable/-/remarkable-2.0.8.tgz#e18816db814cdc087e86b0446f6883754d666088" + integrity sha512-eKXqPZfpQl1kOADjdKchHrp2gwn9qMnGXhH/AtZe0UrklzhGJkawJo/Y/D0AlWcdWoWamFNIum8+/nkAISQVGg== + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -2700,6 +2769,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/unist@^2": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@types/use-sync-external-store@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" @@ -3267,7 +3341,7 @@ arg@5.0.2, arg@^5.0.2: resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== -argparse@^1.0.7: +argparse@^1.0.10, argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== @@ -3430,6 +3504,13 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +autolinker@^3.11.0: + version "3.16.2" + resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-3.16.2.tgz#6bb4f32432fc111b65659336863e653973bfbcc9" + integrity sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA== + dependencies: + tslib "^2.3.0" + autoprefixer@^10.4.13: version "10.4.20" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b" @@ -3791,17 +3872,6 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" -call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -3912,6 +3982,21 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.1.tgz#6dafdb25f9d3349914079f010ba8d0e6ff9cd01e" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== +character-entities-legacy@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" + integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== + +character-entities@^1.0.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" + integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== + +character-reference-invalid@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" + integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== + check-types@^11.2.3: version "11.2.3" resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.2.3.tgz#1ffdf68faae4e941fce252840b1787b8edc93b71" @@ -4051,6 +4136,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^1.0.0: + version "1.0.8" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" + integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== + commander@^12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" @@ -4220,7 +4310,14 @@ create-jest@^29.7.0: jest-util "^29.7.0" prompts "^2.0.1" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -4594,15 +4691,6 @@ define-data-property@^1.0.1, define-data-property@^1.1.4: es-errors "^1.3.0" gopd "^1.0.1" -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -5579,6 +5667,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fault@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13" + integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA== + dependencies: + format "^0.2.0" + faye-websocket@^0.11.3: version "0.11.4" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" @@ -5748,6 +5843,11 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +format@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" + integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -5843,17 +5943,6 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -6025,13 +6114,6 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: dependencies: es-define-property "^1.0.0" -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - has-proto@^1.0.1, has-proto@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" @@ -6056,11 +6138,32 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-parse-selector@^2.0.0: + version "2.2.5" + resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a" + integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ== + +hastscript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" + integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== + dependencies: + "@types/hast" "^2.0.0" + comma-separated-tokens "^1.0.0" + hast-util-parse-selector "^2.0.0" + property-information "^5.0.0" + space-separated-tokens "^1.0.0" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight.js@^10.4.1, highlight.js@~10.7.0: + version "10.7.3" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" + integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== + hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6337,6 +6440,19 @@ ipaddr.js@^2.0.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== +is-alphabetical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" + integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== + +is-alphanumerical@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" + integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A== + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -6413,6 +6529,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" +is-decimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" + integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== + is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" @@ -6454,6 +6575,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-hexadecimal@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" + integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== + is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -7721,6 +7847,20 @@ jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +katex@^0.16.11: + version "0.16.11" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" + integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== + dependencies: + commander "^8.3.0" + +katex@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.6.0.tgz#12418e09121c05c92041b6b3b9fb6bab213cb6f3" + integrity sha512-rS4mY3SvHYg5LtQV6RBcK0if7ur6plyEukAOV+jGGPqFImuzu8fHL6M752iBmRGoUyF0bhZbAPoezehn7xYksA== + dependencies: + match-at "^0.1.0" + keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -7884,6 +8024,14 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" +lowlight@^1.17.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.20.0.tgz#ddb197d33462ad0d93bf19d17b6c301aa3941888" + integrity sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw== + dependencies: + fault "^1.0.0" + highlight.js "~10.7.0" + lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -7939,6 +8087,18 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +markdown-it-katex@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/markdown-it-katex/-/markdown-it-katex-2.0.3.tgz#d7b86a1aea0b9d6496fab4e7919a18fdef589c39" + integrity sha512-nUkkMtRWeg7OpdflamflE/Ho/pWl64Lk9wNBKOmaj33XkQdumhXAIYhI0WO03GeiycPCsxbmX536V5NEXpC3Ng== + dependencies: + katex "^0.6.0" + +match-at@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/match-at/-/match-at-0.1.1.tgz#25d040d291777704d5e6556bbb79230ec2de0540" + integrity sha512-h4Yd392z9mST+dzc+yjuybOGFNOZjmXIPKWjxBd1Bb23r4SmDOsk2NYCU2BMUBGbSpZqwVsZYNq26QS3xfaT3Q== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -8465,6 +8625,18 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" + integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ== + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -9238,6 +9410,16 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +prismjs@^1.27.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + +prismjs@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057" + integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -9267,6 +9449,13 @@ prop-types@^15.5.0, prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^5.0.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" + integrity sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA== + dependencies: + xtend "^4.0.0" + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -9542,6 +9731,17 @@ react-scripts@^5.0.1: optionalDependencies: fsevents "^2.3.2" +react-syntax-highlighter@^15.5.0: + version "15.5.0" + resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20" + integrity sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg== + dependencies: + "@babel/runtime" "^7.3.1" + highlight.js "^10.4.1" + lowlight "^1.17.0" + prismjs "^1.27.0" + refractor "^3.6.0" + react-tabs@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-6.0.2.tgz#bc1065c3828561fee285a8fd045f22e0fcdde1eb" @@ -9650,6 +9850,15 @@ reflect.getprototypeof@^1.0.4: globalthis "^1.0.3" which-builtin-type "^1.1.3" +refractor@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.6.0.tgz#ac318f5a0715ead790fcfb0c71f4dd83d977935a" + integrity sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA== + dependencies: + hastscript "^6.0.0" + parse-entities "^2.0.0" + prismjs "~1.27.0" + regenerate-unicode-properties@^10.1.0: version "10.1.1" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480" @@ -9733,6 +9942,19 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== +remarkable-katex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/remarkable-katex/-/remarkable-katex-1.2.1.tgz#ffb664372b5f3199b2973a2e0a64e8a8f0ee7f20" + integrity sha512-Y1VquJBZnaVsfsVcKW2hmjT+pDL7mp8l5WAVlvuvViltrdok2m1AIKmJv8SsH+mBY84PoMw67t3kTWw1dIm8+g== + +remarkable@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31" + integrity sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA== + dependencies: + argparse "^1.0.10" + autolinker "^3.11.0" + renderkid@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" @@ -10098,18 +10320,6 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" @@ -10179,16 +10389,6 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -side-channel@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" - signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -10285,6 +10485,11 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +space-separated-tokens@^1.0.0: + version "1.1.5" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" + integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -10330,6 +10535,11 @@ stackframe@^1.3.4: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== +state-local@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5" + integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== + static-eval@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42" @@ -10900,6 +11110,11 @@ tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.3.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -11737,6 +11952,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"