From 1b0e8d6c5c18d904027961b5863bbf4874f4b7b9 Mon Sep 17 00:00:00 2001 From: VanessaScherma <135208497+VanessaScherma@users.noreply.github.com> Date: Sun, 8 Dec 2024 13:13:46 +0100 Subject: [PATCH] Adds Digital Twin create and Library features (#1081) - Refactors the classes interacting with gitlab so that the exploration of existing digital assets in a gitlab repository are possible - Adds library page which allows to browse existing assets in a gitlab repository and select them to create new digital twin - Adds feature to create digital twins from clean slate - Adds ability to create composite, fleet and hierarchical digital twins --------- Co-authored-by: vanessa --- client/DEVELOPER.md | 2 + client/config/dev.js | 1 + client/config/local.js | 1 + client/config/prod.js | 1 + client/config/test.js | 5 +- client/env.d.ts | 1 + client/package.json | 5 +- client/src/components/LinkIconsLib.tsx | 5 + .../components/asset/AddToCartButton.tsx | 50 +++ client/src/preview/components/asset/Asset.ts | 6 + .../preview/components/asset/AssetBoard.tsx | 77 ++++- .../preview/components/asset/AssetCard.tsx | 88 +++++- .../preview/components/asset/AssetLibrary.tsx | 94 ++++++ .../components/asset/DetailsButton.tsx | 39 ++- .../src/preview/components/asset/Filter.tsx | 40 +++ .../src/preview/components/cart/CartList.tsx | 24 ++ .../preview/components/cart/ShoppingCart.tsx | 90 ++++++ .../digitaltwins/create/CreateDTDialog.tsx | 74 ++++- .../route/digitaltwins/create/CreatePage.tsx | 66 ++-- .../digitaltwins/create/FileActionButtons.tsx | 68 +++- .../route/digitaltwins/editor/Editor.tsx | 26 ++ .../route/digitaltwins/editor/EditorTab.tsx | 49 ++- .../route/digitaltwins/editor/Sidebar.tsx | 170 ++++++++-- .../digitaltwins/editor/sidebarFetchers.ts | 94 ++++++ .../digitaltwins/editor/sidebarFunctions.ts | 270 ++++++++++++++++ .../digitaltwins/editor/sidebarFunctions.tsx | 263 ---------------- .../digitaltwins/editor/sidebarRendering.tsx | 129 ++++++++ .../digitaltwins/manage/DetailsDialog.tsx | 14 +- .../digitaltwins/manage/ReconfigureDialog.tsx | 77 ++++- .../preview/route/library/LibraryPreview.tsx | 64 ++++ .../route/library/LibraryTabDataPreview.ts | 40 +++ client/src/preview/store/CartAccess.ts | 18 ++ client/src/preview/store/assets.slice.ts | 40 ++- client/src/preview/store/cart.slice.ts | 43 +++ client/src/preview/store/digitalTwin.slice.ts | 30 +- client/src/preview/store/file.slice.ts | 1 + .../preview/store/libraryConfigFiles.slice.ts | 84 +++++ client/src/preview/util/DTAssets.ts | 93 +++++- client/src/preview/util/digitalTwin.ts | 121 ++++++- client/src/preview/util/digitalTwinUtils.ts | 18 ++ client/src/preview/util/fileHandler.ts | 124 +++++++- client/src/preview/util/fileUtils.ts | 48 ++- client/src/preview/util/gitlab.ts | 54 +++- client/src/preview/util/init.ts | 82 ++++- client/src/preview/util/libraryAsset.ts | 86 +++++ client/src/preview/util/libraryManager.ts | 58 ++++ client/src/routes.tsx | 9 + client/src/store/store.ts | 4 + client/src/util/envUtil.ts | 3 +- client/test/README.md | 2 + client/test/preview/__mocks__/global_mocks.ts | 46 ++- .../components/asset/AssetBoard.test.tsx | 48 +-- .../asset/AssetCardExecute.test.tsx | 44 ++- .../create/ConfirmDeleteDialog.test.tsx | 2 +- .../create/CreateDTDialog.test.tsx | 2 + .../digitaltwins/create/CreatePage.test.tsx | 2 + .../create/FileActionButtons.test.tsx | 3 +- .../route/digitaltwins/editor/Editor.test.tsx | 30 +- .../digitaltwins/editor/Sidebar.test.tsx | 32 +- .../editor/SidebarFunctions.test.tsx | 202 ++++++------ .../execute/PipelineUtils.test.tsx | 2 +- .../digitaltwins/manage/ConfigDialog.test.tsx | 263 ++++++++-------- .../digitaltwins/manage/DeleteDialog.test.tsx | 119 +++---- .../manage/DetailsDialog.test.tsx | 133 +++++--- .../route/digitaltwins/manage/utils.ts | 9 +- .../route/library/LibraryPreview.test.tsx | 44 +++ .../components/asset/AddToCartButton.test.tsx | 61 ++++ .../unit/components/asset/AssetBoard.test.tsx | 22 +- .../unit/components/asset/AssetCard.test.tsx | 80 ++--- .../components/asset/AssetLibrary.test.tsx | 56 ++++ .../components/asset/DetailsButton.test.tsx | 11 +- .../unit/components/cart/CartList.test.tsx | 34 ++ .../create/CreateDialogs.test.tsx | 1 + .../digitaltwins/create/CreatePage.test.tsx | 6 - .../create/FileActionButtons.test.tsx | 5 +- .../digitaltwins/editor/Editor.test.tsx | 6 + .../digitaltwins/editor/EditorTab.test.tsx | 133 ++++++-- .../digitaltwins/editor/Sidebar.test.tsx | 137 ++++---- .../editor/SidebarFunctions.test.tsx | 240 -------------- .../editor/sidebarFetchers.test.ts | 125 ++++++++ .../editor/sidebarFunctions.test.ts | 296 ++++++++++++++++++ .../editor/sidebarRendering.test.tsx | 169 ++++++++++ .../digitaltwins/manage/ConfigDialog.test.tsx | 13 +- .../manage/DetailsDialog.test.tsx | 2 + .../preview/unit/store/CartAccess.test.ts | 55 ++++ .../preview/unit/{ => store}/Store.test.ts | 210 +++++++++++-- .../test/preview/unit/util/DTAssets.test.ts | 2 +- .../preview/unit/util/digitalTwin.test.ts | 6 +- .../test/preview/unit/util/fileUtils.test.ts | 17 +- client/test/preview/unit/util/gitlab.test.ts | 11 +- client/test/preview/unit/util/init.test.ts | 55 +--- .../preview/unit/util/libraryAsset.test.ts | 74 +++++ .../preview/unit/util/libraryManager.test.ts | 74 +++++ client/test/unit/util/envUtil.test.ts | 1 + client/yarn.lock | 62 +++- deploy/config/client/env.js | 1 + deploy/config/client/env.local.js | 1 + docs/admin/client/config.md | 3 + 98 files changed, 4472 insertions(+), 1329 deletions(-) create mode 100644 client/src/preview/components/asset/AddToCartButton.tsx create mode 100644 client/src/preview/components/asset/AssetLibrary.tsx create mode 100644 client/src/preview/components/asset/Filter.tsx create mode 100644 client/src/preview/components/cart/CartList.tsx create mode 100644 client/src/preview/components/cart/ShoppingCart.tsx create mode 100644 client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts create mode 100644 client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts delete mode 100644 client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx create mode 100644 client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx create mode 100644 client/src/preview/route/library/LibraryPreview.tsx create mode 100644 client/src/preview/route/library/LibraryTabDataPreview.ts create mode 100644 client/src/preview/store/CartAccess.ts create mode 100644 client/src/preview/store/cart.slice.ts create mode 100644 client/src/preview/store/libraryConfigFiles.slice.ts create mode 100644 client/src/preview/util/libraryAsset.ts create mode 100644 client/src/preview/util/libraryManager.ts create mode 100644 client/test/preview/integration/route/library/LibraryPreview.test.tsx create mode 100644 client/test/preview/unit/components/asset/AddToCartButton.test.tsx create mode 100644 client/test/preview/unit/components/asset/AssetLibrary.test.tsx create mode 100644 client/test/preview/unit/components/cart/CartList.test.tsx delete mode 100644 client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx create mode 100644 client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts create mode 100644 client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts create mode 100644 client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx create mode 100644 client/test/preview/unit/store/CartAccess.test.ts rename client/test/preview/unit/{ => store}/Store.test.ts (59%) create mode 100644 client/test/preview/unit/util/libraryAsset.test.ts create mode 100644 client/test/preview/unit/util/libraryManager.test.ts diff --git a/client/DEVELOPER.md b/client/DEVELOPER.md index 45cbc0504..f89354c19 100644 --- a/client/DEVELOPER.md +++ b/client/DEVELOPER.md @@ -118,6 +118,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', @@ -148,6 +149,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/dev.js b/client/config/dev.js index 82df13a27..f79f041dd 100644 --- a/client/config/dev.js +++ b/client/config/dev.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/local.js b/client/config/local.js index ebf935f90..bc57f0748 100644 --- a/client/config/local.js +++ b/client/config/local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/prod.js b/client/config/prod.js index 8b54ea9c3..2e2954520 100644 --- a/client/config/prod.js +++ b/client/config/prod.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/test.js b/client/config/test.js index 3f4e27d26..b47c87633 100644 --- a/client/config/test.js +++ b/client/config/test.js @@ -9,10 +9,11 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', - 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/env.d.ts b/client/env.d.ts index 6aad96ef7..3ac87cd8d 100644 --- a/client/env.d.ts +++ b/client/env.d.ts @@ -12,6 +12,7 @@ declare global { REACT_APP_WORKBENCHLINK_VSCODE: string; REACT_APP_WORKBENCHLINK_JUPYTERLAB: string; REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: string; + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: string; REACT_APP_WORKBENCHLINK_DT_PREVIEW: string; REACT_APP_CLIENT_ID: string; diff --git a/client/package.json b/client/package.json index 66489c896..7989f5acf 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/dtaas-web", - "version": "0.7.0", + "version": "0.8.0", "description": "Web client for Digital Twin as a Service (DTaaS)", "main": "index.tsx", "author": "prasadtalasila (http://prasad.talasila.in/)", @@ -56,6 +56,7 @@ "@mui/material": "^6.1.1", "@mui/x-tree-view": "^7.19.0", "@reduxjs/toolkit": "^2.2.7", + "@testing-library/react-hooks": "^8.0.1", "@types/react-syntax-highlighter": "^15.5.13", "@types/remarkable": "^2.0.8", "@types/styled-components": "^5.1.32", @@ -70,6 +71,7 @@ "eslint-plugin-jest": "^28.8.3", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.33.2", + "jest-fetch-mock": "^3.0.3", "katex": "^0.16.11", "markdown-it-katex": "^2.0.3", "oidc-client-ts": "^3.0.1", @@ -87,6 +89,7 @@ "redux": "^5.0.1", "remarkable": "^2.0.1", "remarkable-katex": "^1.2.1", + "reselect": "^5.1.1", "resize-observer-polyfill": "^1.5.1", "serve": "^14.2.1", "styled-components": "^6.1.1", diff --git a/client/src/components/LinkIconsLib.tsx b/client/src/components/LinkIconsLib.tsx index 46c2efcf0..a8c107b5d 100644 --- a/client/src/components/LinkIconsLib.tsx +++ b/client/src/components/LinkIconsLib.tsx @@ -7,6 +7,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import GitHubIcon from '@mui/icons-material/GitHub'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import TabIcon from '@mui/icons-material/Tab'; +import LibraryBooksOutlined from '@mui/icons-material/LibraryBooksOutlined'; type LinkIconsType = { [key: string]: { icon: React.ReactElement; name: string | undefined }; @@ -29,6 +30,10 @@ const LinkIcons: LinkIconsType = { icon: , name: 'Jupyter Notebook', }, + LIBRARY_PREVIEW: { + icon: , + name: 'Library page preview', + }, DT_PREVIEW: { icon: , name: 'Digital Twins page preview', diff --git a/client/src/preview/components/asset/AddToCartButton.tsx b/client/src/preview/components/asset/AddToCartButton.tsx new file mode 100644 index 000000000..414c0f4a9 --- /dev/null +++ b/client/src/preview/components/asset/AddToCartButton.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Button } from '@mui/material'; +import LibraryAsset from 'preview/util/libraryAsset'; +import useCart from 'preview/store/CartAccess'; +import { useSelector } from 'react-redux'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; + +interface AddToCartButtonProps { + assetPath: string; + assetPrivacy: boolean; +} + +function AddToCartButton({ assetPath, assetPrivacy }: AddToCartButtonProps) { + const { state: cartState, actions } = useCart(); + const asset = useSelector( + selectAssetByPathAndPrivacy(assetPath, assetPrivacy), + ) as LibraryAsset; + + const isInCart = cartState.assets.some( + (item: LibraryAsset) => + item.path === asset.path && item.isPrivate === asset.isPrivate, + ); + + const handleAddToCart = async () => { + actions.add(asset); + }; + + const handleRemoveFromCart = async () => { + actions.remove(asset); + }; + + return ( + + ); +} + +export default AddToCartButton; diff --git a/client/src/preview/components/asset/Asset.ts b/client/src/preview/components/asset/Asset.ts index 3210e1a13..a83ad17c4 100644 --- a/client/src/preview/components/asset/Asset.ts +++ b/client/src/preview/components/asset/Asset.ts @@ -1,4 +1,10 @@ +import GitlabInstance from 'preview/util/gitlab'; + export interface Asset { name: string; path: string; + type: string; + isPrivate: boolean; + gitlabInstance?: GitlabInstance; + fullDescription?: string; } diff --git a/client/src/preview/components/asset/AssetBoard.tsx b/client/src/preview/components/asset/AssetBoard.tsx index c315bbb9a..9c0b4bf34 100644 --- a/client/src/preview/components/asset/AssetBoard.tsx +++ b/client/src/preview/components/asset/AssetBoard.tsx @@ -1,9 +1,14 @@ import * as React from 'react'; -import { Grid } from '@mui/material'; +import { Grid, CircularProgress } from '@mui/material'; import { useSelector, useDispatch } from 'react-redux'; +import { + deleteAsset, + selectAssetsByTypeAndPrivacy, +} from 'preview/store/assets.slice'; +import { fetchDigitalTwins } from 'preview/util/init'; +import { setShouldFetchDigitalTwins } from 'preview/store/digitalTwin.slice'; import { RootState } from 'store/store'; -import { deleteAsset } from 'preview/store/assets.slice'; -import { fetchAssets } from 'preview/util/init'; +import Filter from './Filter'; import { Asset } from './Asset'; import { AssetCardExecute, AssetCardManage } from './AssetCard'; @@ -45,36 +50,74 @@ const AssetGridItem: React.FC<{ ); const AssetBoard: React.FC = ({ tab }) => { - const assets = useSelector((state: RootState) => state.assets.items); + const allAssets = useSelector( + selectAssetsByTypeAndPrivacy('Digital Twins', true), + ); + const [filter, setFilter] = React.useState(''); const [error, setError] = React.useState(null); + const shouldFetchDigitalTwins = useSelector( + (state: RootState) => state.digitalTwin.shouldFetchDigitalTwins, + ); + const [loading, setLoading] = React.useState(true); const dispatch = useDispatch(); React.useEffect(() => { const fetchData = async () => { - await fetchAssets(dispatch, setError); + setLoading(true); + try { + await fetchDigitalTwins(dispatch, setError); + } finally { + setLoading(false); + dispatch(setShouldFetchDigitalTwins(false)); + } }; - fetchData(); - }, [dispatch]); + + if (shouldFetchDigitalTwins === true) { + fetchData(); + } else { + setLoading(false); + } + }, [dispatch, shouldFetchDigitalTwins]); const handleDelete = (deletedAssetPath: string) => { dispatch(deleteAsset(deletedAssetPath)); }; + const filteredAssets = allAssets.filter((asset) => + asset.name.toLowerCase().includes(filter.toLowerCase()), + ); + if (error) { return {error}; } return ( - - {assets.map((asset) => ( - - ))} - + <> + {loading ? ( + + + + ) : ( + <> + + + {filteredAssets.map((asset) => ( + + ))} + + + )} + ); }; diff --git a/client/src/preview/components/asset/AssetCard.tsx b/client/src/preview/components/asset/AssetCard.tsx index 5c93a8de6..ab7f151c9 100644 --- a/client/src/preview/components/asset/AssetCard.tsx +++ b/client/src/preview/components/asset/AssetCard.tsx @@ -14,16 +14,19 @@ 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 { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import StartStopButton from './StartStopButton'; import LogButton from './LogButton'; import { Asset } from './Asset'; import DetailsButton from './DetailsButton'; import ReconfigureButton from './ReconfigureButton'; import DeleteButton from './DeleteButton'; +import AddToCartButton from './AddToCartButton'; interface AssetCardProps { asset: Asset; buttons?: React.ReactNode; + library?: boolean; } interface AssetCardManageProps { @@ -34,6 +37,7 @@ interface AssetCardManageProps { interface CardButtonsContainerManageProps { assetName: string; + assetPrivacy: boolean; setShowDetails: Dispatch>; setShowReconfigure: Dispatch>; setShowDelete: Dispatch>; @@ -44,6 +48,13 @@ interface CardButtonsContainerExecuteProps { setShowLog: Dispatch>; } +interface CardButtonsContainerLibraryProps { + assetName: string; + assetPath: string; + assetPrivacy: boolean; + setShowDetails: Dispatch>; +} + const Header = styled(Typography)` display: -webkit-box; -webkit-line-clamp: 1; @@ -59,11 +70,17 @@ const Description = styled(Typography)` text-overflow: ellipsis; `; -function CardActionAreaContainer(asset: Asset) { +function CardActionAreaContainer(asset: Asset, library?: boolean) { const digitalTwin = useSelector( - (state: RootState) => state.digitalTwin[asset.name], + (state: RootState) => state.digitalTwin.digitalTwin[asset.name], ); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(asset.path, asset.isPrivate), + ); + + const selectedAsset = library ? libraryAsset : digitalTwin; + return ( @@ -78,7 +95,7 @@ function CardActionAreaContainer(asset: Asset) { }} > - {digitalTwin.description} + {selectedAsset!.description} @@ -88,13 +105,18 @@ function CardActionAreaContainer(asset: Asset) { function CardButtonsContainerManage({ assetName, + assetPrivacy, setShowDetails, setShowReconfigure, setShowDelete, }: CardButtonsContainerManageProps) { return ( - + @@ -120,7 +142,27 @@ function CardButtonsContainerExecute({ ); } -function AssetCard({ asset, buttons }: AssetCardProps) { +function CardButtonsContainerLibrary({ + assetName, + assetPath, + assetPrivacy, + setShowDetails, +}: CardButtonsContainerLibraryProps) { + return ( + + + + + ); +} + +function AssetCard({ asset, buttons, library }: AssetCardProps) { return (
{formatName(asset.name)}
- + {buttons}
); @@ -153,6 +195,7 @@ function AssetCardManage({ asset, onDelete }: AssetCardManageProps) { buttons={ ('success'); + const [showDetails, setShowDetails] = useState(false); + + return ( + <> + + } + library={true} + /> + + + ); +} + +export { AssetCardManage, AssetCardExecute, AssetCardLibrary }; diff --git a/client/src/preview/components/asset/AssetLibrary.tsx b/client/src/preview/components/asset/AssetLibrary.tsx new file mode 100644 index 000000000..251ca84f5 --- /dev/null +++ b/client/src/preview/components/asset/AssetLibrary.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { Grid, CircularProgress, Box } from '@mui/material'; +import { AssetCardLibrary } from 'preview/components/asset/AssetCard'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectAssetsByTypeAndPrivacy } from 'preview/store/assets.slice'; +import { fetchLibraryAssets } from 'preview/util/init'; +import Filter from 'preview/components/asset/Filter'; +import { useState } from 'react'; + +const outerGridContainerProps = { + container: true, + spacing: 2, + sx: { + justifyContent: 'flex-start', + overflow: 'auto', + maxHeight: 'inherent', + }, +}; + +function AssetLibrary(props: { pathToAssets: string; privateRepo: boolean }) { + const assets = useSelector( + selectAssetsByTypeAndPrivacy(props.pathToAssets, props.privateRepo), + ); + const [filter, setFilter] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const dispatch = useDispatch(); + + React.useEffect(() => { + const fetchData = async () => { + setLoading(true); + await fetchLibraryAssets( + dispatch, + setError, + props.pathToAssets, + props.privateRepo, + ); + setLoading(false); + }; + fetchData(); + }, [dispatch, props.pathToAssets, props.privateRepo]); + + const filteredAssets = assets.filter((asset) => + asset.name.toLowerCase().includes(filter.toLowerCase()), + ); + + if (loading) { + return ( + + + + ); + } + + if (!assets.length) { + return {error}; + } + + return ( + <> + + + + + {filteredAssets.map((asset, i) => ( + + + + ))} + + + ); +} + +export default AssetLibrary; diff --git a/client/src/preview/components/asset/DetailsButton.tsx b/client/src/preview/components/asset/DetailsButton.tsx index cf1862989..86573b34d 100644 --- a/client/src/preview/components/asset/DetailsButton.tsx +++ b/client/src/preview/components/asset/DetailsButton.tsx @@ -2,30 +2,61 @@ import * as React from 'react'; import { Dispatch, SetStateAction } from 'react'; import { Button } from '@mui/material'; import { useSelector } from 'react-redux'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import { selectDigitalTwinByName } from '../../store/digitalTwin.slice'; import DigitalTwin from '../../util/digitalTwin'; interface DialogButtonProps { assetName: string; - setShowDetails: Dispatch>; + assetPrivacy: boolean; + setShowDetails: Dispatch>; + library?: boolean; + assetPath?: string; } export const handleToggleDetailsDialog = async ( - digitalTwin: DigitalTwin, + digitalTwin: DigitalTwin | LibraryAsset, setShowDetails: Dispatch>, ) => { await digitalTwin.getFullDescription(); setShowDetails(true); }; -function DetailsButton({ assetName, setShowDetails }: DialogButtonProps) { +export const handleToggleDetailsLibraryDialog = async ( + asset: LibraryAsset | DigitalTwin, + setShowDetails: Dispatch>, +) => { + await asset.getFullDescription(); + setShowDetails(true); +}; + +function DetailsButton({ + assetName, + assetPrivacy, + setShowDetails, + library, + assetPath, +}: DialogButtonProps) { const digitalTwin = useSelector(selectDigitalTwinByName(assetName)); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(assetPath || '', assetPrivacy), + ); + + const asset = library ? libraryAsset : digitalTwin; + return ( diff --git a/client/src/preview/components/asset/Filter.tsx b/client/src/preview/components/asset/Filter.tsx new file mode 100644 index 000000000..18d954afb --- /dev/null +++ b/client/src/preview/components/asset/Filter.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { TextField, Box, IconButton } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; + +interface FilterProps { + placeholder?: string; + value: string; + onChange: (value: string) => void; +} + +const Filter: React.FC = ({ + placeholder = 'Search by name', + value, + onChange, +}) => { + const handleClear = () => onChange(''); + + return ( + + + onChange(e.target.value)} + sx={{ maxWidth: 300 }} + /> + {value && ( + + + + )} + + ); +}; + +export default Filter; diff --git a/client/src/preview/components/cart/CartList.tsx b/client/src/preview/components/cart/CartList.tsx new file mode 100644 index 000000000..450073761 --- /dev/null +++ b/client/src/preview/components/cart/CartList.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import useCart from 'preview/store/CartAccess'; +import LibraryAsset from 'preview/util/libraryAsset'; + +function CartList() { + const { state } = useCart(); + return ( +
    + {state.assets.map((a, i) => ( + + ))} +
+ ); +} + +function CartItemRender(props: { asset: LibraryAsset }) { + const displayPath = props.asset.isPrivate + ? props.asset.path + : `common/${props.asset.path}`; + + return
  • {displayPath}
  • ; +} + +export default CartList; diff --git a/client/src/preview/components/cart/ShoppingCart.tsx b/client/src/preview/components/cart/ShoppingCart.tsx new file mode 100644 index 000000000..24645d8c1 --- /dev/null +++ b/client/src/preview/components/cart/ShoppingCart.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Box, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import useCart from 'preview/store/CartAccess'; +import { removeAllFiles } from 'preview/store/libraryConfigFiles.slice'; +import { useDispatch } from 'react-redux'; +import CartList from './CartList'; + +function ShoppingCart() { + const { actions } = useCart(); + const navigate = useNavigate(); + const [openDialog, setOpenDialog] = useState(false); + const dispatch = useDispatch(); + + const handleClearCart = () => { + actions.clear(); + setOpenDialog(false); + dispatch(removeAllFiles()); + }; + + return ( + + + + + + + + + + + setOpenDialog(false)}> + Confirm Clear + + Are you sure you want to clear? + + + + + + + + ); +} + +export default ShoppingCart; diff --git a/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx b/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx index 3890624fb..8c25f209e 100644 --- a/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx +++ b/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx @@ -1,24 +1,32 @@ import * as React from 'react'; -import { Dispatch, SetStateAction } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import { Dialog, DialogActions, DialogContent, Typography, Button, + CircularProgress, + Box, } from '@mui/material'; import { FileState, removeAllCreationFiles } from 'preview/store/file.slice'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'store/store'; import DigitalTwin from 'preview/util/digitalTwin'; import { showSnackbar } from 'preview/store/snackbar.slice'; -import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; +import { + setDigitalTwin, + setShouldFetchDigitalTwins, +} from 'preview/store/digitalTwin.slice'; import { addDefaultFiles, defaultFiles, validateFiles, } from 'preview/util/fileUtils'; import { initDigitalTwin } from 'preview/util/init'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; +import LibraryAsset from 'preview/util/libraryAsset'; +import useCart from 'preview/store/CartAccess'; interface CreateDTDialogProps { open: boolean; @@ -52,6 +60,7 @@ const handleSuccess = ( }), ); dispatch(setDigitalTwin({ assetName: newDigitalTwinName, digitalTwin })); + dispatch(setShouldFetchDigitalTwins(true)); dispatch(removeAllCreationFiles()); addDefaultFiles(defaultFiles, files, dispatch); @@ -71,6 +80,8 @@ const resetDialogAndForm = ( const handleConfirm = async ( files: FileState[], + libraryFiles: LibraryConfigFile[], + cartAssets: LibraryAsset[], setErrorMessage: Dispatch>, newDigitalTwinName: string, dispatch: ReturnType, @@ -79,11 +90,18 @@ const handleConfirm = async ( setFileContent: Dispatch>, setFileType: Dispatch>, setNewDigitalTwinName: Dispatch>, + setIsLoading: Dispatch>, + actions: ReturnType['actions'], ) => { - if (validateFiles(files, setErrorMessage)) return; + setIsLoading(true); + + if (validateFiles(files, libraryFiles, setErrorMessage)) { + setIsLoading(false); + return; + } const digitalTwin = await initDigitalTwin(newDigitalTwinName); - const result = await digitalTwin.create(files); + const result = await digitalTwin.create(files, cartAssets, libraryFiles); if (result.startsWith('Error')) { handleError(result, dispatch); @@ -98,6 +116,8 @@ const handleConfirm = async ( setFileType, ); setNewDigitalTwinName(''); + actions.clear(); + setIsLoading(false); }; const CreateDTDialog: React.FC = ({ @@ -112,8 +132,16 @@ const CreateDTDialog: React.FC = ({ setFileType, }) => { const files: FileState[] = useSelector((state: RootState) => state.files); + const libraryFiles = useSelector( + (state: RootState) => state.libraryConfigFiles, + ); + const cartAssets = useSelector((state: RootState) => state.cart.assets); const dispatch = useDispatch(); + const { actions } = useCart(); + + const [isLoading, setIsLoading] = useState(false); + return ( @@ -122,24 +150,33 @@ const CreateDTDialog: React.FC = ({ {newDigitalTwinName} digital twin? {errorMessage} + {isLoading && ( + + + + )} - + {!isLoading && ( + + )} diff --git a/client/src/preview/route/digitaltwins/create/CreatePage.tsx b/client/src/preview/route/digitaltwins/create/CreatePage.tsx index fddeda661..b1f95a4f3 100644 --- a/client/src/preview/route/digitaltwins/create/CreatePage.tsx +++ b/client/src/preview/route/digitaltwins/create/CreatePage.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import { Dispatch, SetStateAction, useState } from 'react'; -import { Box, Button, TextField } from '@mui/material'; +import { Box, Button, TextField, Tooltip } from '@mui/material'; import Editor from 'preview/route/digitaltwins/editor/Editor'; import CreateDialogs from './CreateDialogs'; import CustomSnackbar from '../Snackbar'; -import FileActionButtons from './FileActionButtons'; interface CreatePageProps { newDigitalTwinName: string; @@ -19,11 +18,18 @@ function DigitalTwinNameInput({ onChange: (e: React.ChangeEvent) => void; }) { return ( - + @@ -46,21 +52,37 @@ function ActionButtons({ display: 'flex', justifyContent: 'flex-end', width: '100%', - marginBottom: 2, gap: 1, + position: 'fixed', + bottom: 0, + left: 0, + backgroundColor: 'white', + padding: 2, + boxShadow: '0 -2px 5px rgba(0,0,0,0.1)', + zIndex: 10, }} > - + + + + ); } @@ -72,6 +94,9 @@ function CreatePage({ const [fileName, setFileName] = useState(''); const [fileContent, setFileContent] = useState(''); const [fileType, setFileType] = useState(''); + const [filePrivacy, setFilePrivacy] = useState(''); + const [isLibraryFile, setIsLibraryFile] = useState(false); + const [libraryAssetPath, setLibraryAssetPath] = useState(''); const [openChangeFileNameDialog, setOpenChangeFileNameDialog] = useState(false); const [openDeleteFileDialog, setOpenDeleteFileDialog] = useState(false); @@ -93,17 +118,12 @@ function CreatePage({ - setNewDigitalTwinName(e.target.value)} @@ -117,8 +137,16 @@ function CreatePage({ setFileName={setFileName} fileContent={fileContent} setFileContent={setFileContent} + filePrivacy={filePrivacy} + setFilePrivacy={setFilePrivacy} fileType={fileType} setFileType={setFileType} + isLibraryFile={isLibraryFile} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={libraryAssetPath} + setLibraryAssetPath={setLibraryAssetPath} + setOpenDeleteFileDialog={setOpenDeleteFileDialog} + setOpenChangeFileNameDialog={setOpenChangeFileNameDialog} /> diff --git a/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx b/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx index 0d023a8bb..1cc96d76f 100644 --- a/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx +++ b/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx @@ -2,34 +2,68 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import { isFileDeletable, isFileModifiable } from 'preview/util/fileUtils'; +import { Tooltip } from '@mui/material'; function FileActionButtons({ fileName, setOpenDeleteFileDialog, setOpenChangeFileNameDialog, + isLibraryFile, }: { fileName: string; setOpenDeleteFileDialog: React.Dispatch>; setOpenChangeFileNameDialog: React.Dispatch>; + isLibraryFile: boolean; }) { + const deleteFileDisabled = !( + isFileDeletable(fileName) && + fileName && + !isLibraryFile + ); + const changeFileNameDisabled = !( + isFileModifiable(fileName) && + fileName && + !isLibraryFile + ); + return ( - - {isFileDeletable(fileName) && fileName && ( - - )} - {isFileModifiable(fileName) && fileName && ( - - )} + + + + + + + + + + + ); } diff --git a/client/src/preview/route/digitaltwins/editor/Editor.tsx b/client/src/preview/route/digitaltwins/editor/Editor.tsx index affbc767d..24f49a222 100644 --- a/client/src/preview/route/digitaltwins/editor/Editor.tsx +++ b/client/src/preview/route/digitaltwins/editor/Editor.tsx @@ -14,6 +14,14 @@ interface EditorProps { setFileContent: React.Dispatch>; fileType: string; setFileType: React.Dispatch>; + filePrivacy: string; + setFilePrivacy: React.Dispatch>; + isLibraryFile: boolean; + setIsLibraryFile: React.Dispatch>; + libraryAssetPath: string; + setLibraryAssetPath: React.Dispatch>; + setOpenDeleteFileDialog?: React.Dispatch>; + setOpenChangeFileNameDialog?: React.Dispatch>; } function Editor({ @@ -25,6 +33,14 @@ function Editor({ setFileContent, fileType, setFileType, + filePrivacy, + setFilePrivacy, + isLibraryFile, + setIsLibraryFile, + libraryAssetPath, + setLibraryAssetPath, + setOpenDeleteFileDialog, + setOpenChangeFileNameDialog, }: EditorProps) { const [activeTab, setActiveTab] = useState(0); @@ -48,7 +64,14 @@ function Editor({ setFileName={setFileName} setFileContent={setFileContent} setFileType={setFileType} + setFilePrivacy={setFilePrivacy} + setIsLibraryFile={setIsLibraryFile} + setLibraryAssetPath={setLibraryAssetPath} tab={tab} + fileName={fileName} + isLibraryFile={isLibraryFile} + setOpenDeleteFileDialog={setOpenDeleteFileDialog || undefined} + setOpenChangeFileNameDialog={setOpenChangeFileNameDialog || undefined} /> @@ -85,7 +108,10 @@ function Editor({ tab={tab} fileName={fileName} fileContent={fileContent} + filePrivacy={filePrivacy} setFileContent={setFileContent} + isLibraryFile={isLibraryFile} + libraryAssetPath={libraryAssetPath} /> )} {activeTab === 1 && ( diff --git a/client/src/preview/route/digitaltwins/editor/EditorTab.tsx b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx index 5201f5eb5..cdfa4d148 100644 --- a/client/src/preview/route/digitaltwins/editor/EditorTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx @@ -2,43 +2,76 @@ import * as React from 'react'; import { useState, useEffect, Dispatch, SetStateAction } from 'react'; import Editor from '@monaco-editor/react'; import { useDispatch } from 'react-redux'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; import { addOrUpdateFile } from '../../../store/file.slice'; interface EditorTabProps { tab: string; fileName: string; fileContent: string; + filePrivacy: string; + isLibraryFile: boolean; + libraryAssetPath: string; setFileContent: Dispatch>; } -const handleEditorChange = ( +export const handleEditorChange = ( tab: string, value: string | undefined, setEditorValue: Dispatch>, setFileContent: Dispatch>, fileName: string, + filePrivacy: string, + isLibraryFile: boolean, + libraryAssetPath: string, dispatch: ReturnType, ) => { const updatedValue = value || ''; setEditorValue(updatedValue); setFileContent(updatedValue); + const isPrivate = filePrivacy === 'private'; + if (tab === 'create') { + if (!isLibraryFile) { + dispatch( + addOrUpdateFile({ + name: fileName, + content: updatedValue, + isNew: true, + isModified: true, + }), + ); + } else { + dispatch( + addOrUpdateLibraryFile({ + assetPath: libraryAssetPath, + fileName, + fileContent: updatedValue, + isNew: true, + isModified: true, + isPrivate, + }), + ); + } + } else if (!isLibraryFile && libraryAssetPath === '') { dispatch( addOrUpdateFile({ name: fileName, content: updatedValue, - isNew: true, + isNew: false, isModified: true, }), ); } else { dispatch( - addOrUpdateFile({ - name: fileName, - content: updatedValue, + addOrUpdateLibraryFile({ + assetPath: libraryAssetPath, + fileName, + fileContent: updatedValue, isNew: false, isModified: true, + isPrivate: true, }), ); } @@ -48,6 +81,9 @@ function EditorTab({ tab, fileName, fileContent, + filePrivacy, + isLibraryFile, + libraryAssetPath, setFileContent, }: EditorTabProps) { const [editorValue, setEditorValue] = useState(fileContent); @@ -70,6 +106,9 @@ function EditorTab({ setEditorValue, setFileContent, fileName, + filePrivacy, + isLibraryFile, + libraryAssetPath, dispatch, ) } diff --git a/client/src/preview/route/digitaltwins/editor/Sidebar.tsx b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx index f10336885..99a095d49 100644 --- a/client/src/preview/route/digitaltwins/editor/Sidebar.tsx +++ b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx @@ -1,26 +1,32 @@ import * as React from 'react'; import { useEffect, useState, Dispatch, SetStateAction } from 'react'; -import { Grid, CircularProgress, Button } from '@mui/material'; +import { Grid, CircularProgress, Button, Box } from '@mui/material'; import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'store/store'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import { getFilteredFileNames } from 'preview/util/fileUtils'; import { FileState } from '../../../store/file.slice'; import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; -import { - fetchData, - getFilteredFileNames, - handleAddFileClick, - renderFileSection, - renderFileTreeItems, -} from './sidebarFunctions'; +import { fetchData } from './sidebarFetchers'; +import { handleAddFileClick } from './sidebarFunctions'; +import { renderFileTreeItems, renderFileSection } from './sidebarRendering'; import SidebarDialog from './SidebarDialog'; +import FileActionButtons from '../create/FileActionButtons'; interface SidebarProps { name?: string; setFileName: Dispatch>; setFileContent: Dispatch>; setFileType: Dispatch>; + setFilePrivacy: Dispatch>; + setIsLibraryFile: Dispatch>; + setLibraryAssetPath: Dispatch>; tab: string; + fileName: string; + isLibraryFile: boolean; + setOpenDeleteFileDialog?: Dispatch>; + setOpenChangeFileNameDialog?: Dispatch>; } const Sidebar = ({ @@ -28,7 +34,14 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, tab, + fileName, + isLibraryFile, + setOpenDeleteFileDialog, + setOpenChangeFileNameDialog, }: SidebarProps) => { const [isLoading, setIsLoading] = useState(!!name); const [newFileName, setNewFileName] = useState(''); @@ -39,17 +52,46 @@ const Sidebar = ({ name ? selectDigitalTwinByName(name)(state) : null, ); const files: FileState[] = useSelector((state: RootState) => state.files); + + const assets = useSelector((state: RootState) => state.cart.assets); + const libraryFiles = useSelector( + (state: RootState) => state.libraryConfigFiles, + ); + const dispatch = useDispatch(); useEffect(() => { - if (name && digitalTwin) { - const loadData = async () => { + const loadFiles = async () => { + if (name && digitalTwin) { await fetchData(digitalTwin); - setIsLoading(false); - }; - loadData(); - } - }, [name, digitalTwin]); + } + + if (tab === 'create') { + if (assets.length > 0) { + await Promise.all( + assets.map(async (asset) => { + await asset.getConfigFiles(); + asset.configFiles.forEach((configFile) => { + dispatch( + addOrUpdateLibraryFile({ + assetPath: asset.path, + fileName: configFile, + fileContent: '', + isNew: true, + isModified: false, + isPrivate: asset.isPrivate, + }), + ); + }); + }), + ); + } + } + setIsLoading(false); + }; + + loadFiles(); + }, [name, digitalTwin, assets, dispatch, tab]); if (isLoading) { return ( @@ -88,15 +130,25 @@ const Sidebar = ({ }} > {tab === 'create' && ( - - )} + + + + + + + )} {name ? ( - <> + {renderFileTreeItems( 'Description', digitalTwin!.descriptionFiles, @@ -118,8 +170,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileTreeItems( 'Configuration', @@ -128,8 +184,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileTreeItems( 'Lifecycle', @@ -138,12 +198,35 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} - + {digitalTwin!.assetFiles.map((assetFolder) => + renderFileTreeItems( + `${assetFolder.assetPath} configuration`, + assetFolder.fileNames, + digitalTwin!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + true, + libraryFiles, + assetFolder.assetPath, + ), + )} + ) : ( - <> + {renderFileSection( 'Description', 'description', @@ -152,8 +235,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileSection( 'Configuration', @@ -163,8 +250,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileSection( 'Lifecycle', @@ -174,10 +265,35 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + )} + {assets.map((asset) => + renderFileSection( + asset.isPrivate + ? `${asset.name} configuration` + : `common/${asset.name} configuration`, + 'config', + asset.configFiles, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + true, + libraryFiles, + ), )} - + )} diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts b/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts new file mode 100644 index 000000000..a1d98a3a6 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts @@ -0,0 +1,94 @@ +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { updateFileState } from 'preview/util/fileUtils'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; + +export const fetchData = async (digitalTwin: DigitalTwin) => { + await digitalTwin.getDescriptionFiles(); + await digitalTwin.getLifecycleFiles(); + await digitalTwin.getConfigFiles(); + await digitalTwin.getAssetFiles(); +}; + +export const fetchAndSetFileContent = async ( + fileName: string, + digitalTwin: DigitalTwin | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + library?: boolean, + assetPath?: string, +) => { + try { + let fileContent; + if (library) { + fileContent = await digitalTwin!.DTAssets.getLibraryFileContent( + assetPath!, + fileName, + ); + } else { + fileContent = await digitalTwin!.DTAssets.getFileContent(fileName); + } + if (fileContent) { + updateFileState( + fileName, + fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + } catch { + setFileContent(`Error fetching ${fileName} content`); + } +}; + +export const fetchAndSetFileLibraryContent = async ( + fileName: string, + libraryAsset: LibraryAsset | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + isNew: boolean, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, +) => { + try { + const fileContent = await libraryAsset!.libraryManager.getFileContent( + libraryAsset!.isPrivate, + libraryAsset!.path, + fileName, + ); + + dispatch!( + addOrUpdateLibraryFile({ + assetPath: libraryAsset!.path, + fileName, + fileContent, + isNew, + isModified: false, + isPrivate: libraryAsset!.isPrivate, + }), + ); + if (fileContent) { + updateFileState( + fileName, + fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + setIsLibraryFile(true); + setLibraryAssetPath(libraryAsset!.path); + } catch { + setFileContent(`Error fetching ${fileName} content`); + } +}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts new file mode 100644 index 000000000..aece16bbc --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts @@ -0,0 +1,270 @@ +import { addOrUpdateFile, FileState } from 'preview/store/file.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { + addOrUpdateLibraryFile, + LibraryConfigFile, +} from 'preview/store/libraryConfigFiles.slice'; +import { + getFileTypeFromExtension, + updateFileState, +} from 'preview/util/fileUtils'; +import { + fetchAndSetFileContent, + fetchAndSetFileLibraryContent, +} from './sidebarFetchers'; + +export const handleFileClick = ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + if (tab === 'create') { + handleCreateFileClick( + fileName, + asset, + files, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + libraryFiles || undefined, + ); + } else if (tab === 'reconfigure') { + handleReconfigureFileClick( + fileName, + asset, + files, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + library || undefined, + libraryFiles || undefined, + assetPath || undefined, + ); + } +}; + +export const handleCreateFileClick = ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + files: FileState[], + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + libraryFiles?: LibraryConfigFile[], +) => { + if (asset instanceof DigitalTwin || asset === null) { + const newFile = files.find((file) => file.name === fileName && file.isNew); + if (newFile) { + updateFileState( + newFile.name, + newFile.content, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + setIsLibraryFile(false); + setLibraryAssetPath(''); + } + } else { + const libraryFile = libraryFiles!.find( + (file) => + file.fileName === fileName && + file.assetPath === asset!.path && + file.isPrivate === asset!.isPrivate, + ); + if (libraryFile?.isModified) { + updateFileState( + libraryFile.fileName, + libraryFile.fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + asset.isPrivate, + ); + setIsLibraryFile(true); + setLibraryAssetPath(libraryFile.assetPath); + } else { + fetchAndSetFileLibraryContent( + libraryFile!.fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + true, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + ); + } + } +}; + +export const handleReconfigureFileClick = async ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + files: FileState[], + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + if (asset instanceof DigitalTwin || asset === null) { + if (library === undefined) { + const modifiedFile = files.find( + (file) => file.name === fileName && file.isModified && !file.isNew, + ); + if (modifiedFile) { + updateFileState( + modifiedFile.name, + modifiedFile.content, + setFileName, + setFileContent, + setFileType, + setFileType, + ); + } else { + fetchAndSetFileContent( + fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + setIsLibraryFile(false); + setLibraryAssetPath(''); + } else { + const modifiedLibraryFile = libraryFiles!.find( + (file) => file.fileName === fileName && file.assetPath === assetPath, + ); + if (modifiedLibraryFile?.isModified) { + updateFileState( + modifiedLibraryFile.fileName, + modifiedLibraryFile.fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } else { + fetchAndSetFileContent( + fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + library, + assetPath, + ); + const fileContent = await asset!.DTAssets.getLibraryFileContent( + assetPath!, + fileName, + ); + dispatch!( + addOrUpdateLibraryFile({ + assetPath: assetPath!, + fileName, + fileContent, + isNew: false, + isModified: false, + isPrivate: true, + }), + ); + } + setIsLibraryFile(true); + setLibraryAssetPath!(assetPath!); + } + } +}; + +export const handleAddFileClick = ( + setIsFileNameDialogOpen: Dispatch>, +) => { + setIsFileNameDialogOpen(true); +}; + +export const handleCloseFileNameDialog = ( + setIsFileNameDialogOpen: Dispatch>, + setNewFileName: Dispatch>, + setErrorMessage: Dispatch>, +) => { + setIsFileNameDialogOpen(false); + setNewFileName(''); + setErrorMessage(''); +}; + +export const handleFileSubmit = ( + files: FileState[], + newFileName: string, + setErrorMessage: Dispatch>, + dispatch: ReturnType, + setIsFileNameDialogOpen: Dispatch>, + setNewFileName: Dispatch>, +) => { + const fileExists = files.some( + (fileStore: { name: string }) => fileStore.name === newFileName, + ); + + if (fileExists) { + setErrorMessage('A file with this name already exists.'); + return; + } + + if (newFileName === '') { + setErrorMessage("File name can't be empty."); + return; + } + + setErrorMessage(''); + const type = getFileTypeFromExtension(newFileName); + + dispatch( + addOrUpdateFile({ + name: newFileName, + content: '', + isNew: true, + isModified: false, + type, + }), + ); + + setIsFileNameDialogOpen(false); + setNewFileName(''); +}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx deleted file mode 100644 index badacec99..000000000 --- a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { addOrUpdateFile, FileState } from 'preview/store/file.slice'; -import DigitalTwin from 'preview/util/digitalTwin'; -import { Dispatch, SetStateAction } from 'react'; -import { useDispatch } from 'react-redux'; -import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; -import * as React from 'react'; - -export const getFileTypeFromExtension = (fileName: string): string => { - const extension = fileName.split('.').pop()?.toLowerCase(); - if (extension === 'md') return 'description'; - if (extension === 'json' || extension === 'yaml' || extension === 'yml') - return 'config'; - return 'lifecycle'; -}; - -export const fetchData = async (digitalTwin: DigitalTwin) => { - await digitalTwin.getDescriptionFiles(); - await digitalTwin.getLifecycleFiles(); - await digitalTwin.getConfigFiles(); -}; - -export const handleFileClick = ( - fileName: string, - digitalTwin: DigitalTwin | null, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => { - if (tab === 'create') { - handleCreateFileClick( - fileName, - files, - setFileName, - setFileContent, - setFileType, - ); - } else if (tab === 'reconfigure') { - handleReconfigureFileClick( - fileName, - digitalTwin, - files, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const renderFileTreeItems = ( - label: string, - filesToRender: string[], - digitalTwin: DigitalTwin, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => ( - - {filesToRender.map((item) => ( - - handleFileClick( - item, - digitalTwin!, - setFileName, - setFileContent, - setFileType, - files, - tab, - ) - } - /> - ))} - -); - -export const getFilteredFileNames = (type: string, files: FileState[]) => - files - .filter( - (file) => file.isNew && getFileTypeFromExtension(file.name) === type, - ) - .map((file) => file.name); - -export const renderFileSection = ( - label: string, - type: string, - filesToRender: string[], - digitalTwin: DigitalTwin, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => ( - - {filesToRender.map((item) => ( - - handleFileClick( - item, - digitalTwin!, - setFileName, - setFileContent, - setFileType, - files, - tab, - ) - } - /> - ))} - -); - -export const handleCreateFileClick = ( - fileName: string, - files: FileState[], - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - const newFile = files.find((file) => file.name === fileName && file.isNew); - if (newFile) { - updateFileState( - newFile.name, - newFile.content, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const handleReconfigureFileClick = ( - fileName: string, - digitalTwin: DigitalTwin | null, - files: FileState[], - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - const modifiedFile = files.find( - (file) => file.name === fileName && file.isModified && !file.isNew, - ); - if (modifiedFile) { - updateFileState( - modifiedFile.name, - modifiedFile.content, - setFileName, - setFileContent, - setFileType, - ); - } else { - fetchAndSetFileContent( - fileName, - digitalTwin, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const fetchAndSetFileContent = async ( - fileName: string, - digitalTwin: DigitalTwin | null, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - try { - const fileContent = await digitalTwin!.DTAssets.getFileContent(fileName); - if (fileContent) { - updateFileState( - fileName, - fileContent, - setFileName, - setFileContent, - setFileType, - ); - } - } catch { - setFileContent(`Error fetching ${fileName} content`); - } -}; - -export const updateFileState = ( - fileName: string, - fileContent: string, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - setFileName(fileName); - setFileContent(fileContent); - setFileType(fileName.split('.').pop()!); -}; - -export const handleAddFileClick = ( - setIsFileNameDialogOpen: Dispatch>, -) => { - setIsFileNameDialogOpen(true); -}; - -export const handleCloseFileNameDialog = ( - setIsFileNameDialogOpen: Dispatch>, - setNewFileName: Dispatch>, - setErrorMessage: Dispatch>, -) => { - setIsFileNameDialogOpen(false); - setNewFileName(''); - setErrorMessage(''); -}; - -export const handleFileSubmit = ( - files: FileState[], - newFileName: string, - setErrorMessage: Dispatch>, - dispatch: ReturnType, - setIsFileNameDialogOpen: Dispatch>, - setNewFileName: Dispatch>, -) => { - const fileExists = files.some( - (fileStore: { name: string }) => fileStore.name === newFileName, - ); - - if (fileExists) { - setErrorMessage('A file with this name already exists.'); - return; - } - - if (newFileName === '') { - setErrorMessage("File name can't be empty."); - return; - } - - setErrorMessage(''); - const type = getFileTypeFromExtension(newFileName); - - dispatch( - addOrUpdateFile({ - name: newFileName, - content: '', - isNew: true, - isModified: false, - type, - }), - ); - - setIsFileNameDialogOpen(false); - setNewFileName(''); -}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx b/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx new file mode 100644 index 000000000..10f9031a8 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; +import { FileState } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import { handleFileClick } from './sidebarFunctions'; + +export const renderFileTreeItems = ( + label: string, + filesToRender: string[], + asset: DigitalTwin | LibraryAsset, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + dispatch: ReturnType, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + const baseLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${label.toLowerCase()}` + : label.toLowerCase(); + + return ( + + {filesToRender.map((item, index) => { + const itemLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${item}` + : item; + + return ( + + handleFileClick( + item, + asset!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + library || undefined, + libraryFiles || undefined, + assetPath || undefined, + ) + } + /> + ); + })} + + ); +}; + +export const renderFileSection = ( + label: string, + type: string, + filesToRender: string[], + asset: DigitalTwin | LibraryAsset, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + dispatch: ReturnType, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + library?: boolean, + fileLibrary?: LibraryConfigFile[], +) => { + const baseLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${label.toLowerCase()}` + : label.toLowerCase(); + + return ( + + {filesToRender.map((item, index) => ( + + handleFileClick( + item, + asset!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + library || undefined, + fileLibrary || undefined, + ) + } + /> + ))} + + ); +}; diff --git a/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx index 145cb9662..69ad5a906 100644 --- a/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx +++ b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx @@ -6,12 +6,16 @@ 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 { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; interface DetailsDialogProps { showDialog: boolean; setShowDialog: Dispatch>; name: string; + isPrivate: boolean; + library?: boolean; + path?: string; } const handleCloseDetailsDialog = ( @@ -24,8 +28,16 @@ function DetailsDialog({ showDialog, setShowDialog, name, + isPrivate, + library, + path, }: DetailsDialogProps) { const digitalTwin = useSelector(selectDigitalTwinByName(name)); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(path || '', isPrivate), + ); + + const asset = library ? libraryAsset : digitalTwin; const md = new Remarkable({ html: true, @@ -37,7 +49,7 @@ function DetailsDialog({
    (''); const [fileContent, setFileContent] = useState(''); const [fileType, setFileType] = useState(''); + const [filePrivacy, setFilePrivacy] = useState(''); + const [isLibraryFile, setIsLibraryFile] = useState(false); + const [libraryAssetPath, setLibraryAssetPath] = useState(''); const [openSaveDialog, setOpenSaveDialog] = useState(false); const [openCancelDialog, setOpenCancelDialog] = useState(false); const digitalTwin = useSelector(selectDigitalTwinByName(name)); const modifiedFiles = useSelector(selectModifiedFiles); + const modifiedLibraryFiles = useSelector(selectModifiedLibraryFiles); const dispatch = useDispatch(); const handleSave = () => setOpenSaveDialog(true); @@ -57,13 +66,20 @@ function ReconfigureDialog({ const handleCloseCancelDialog = () => setOpenCancelDialog(false); const handleConfirmSave = async () => { - await saveChanges(modifiedFiles, digitalTwin, dispatch, name); + await saveChanges( + modifiedFiles, + modifiedLibraryFiles, + digitalTwin, + dispatch, + name, + ); setOpenSaveDialog(false); setShowDialog(false); }; const handleConfirmCancel = () => { dispatch(removeAllModifiedFiles()); + dispatch(removeAllModifiedLibraryFiles()); setOpenCancelDialog(false); setShowDialog(false); }; @@ -82,6 +98,12 @@ function ReconfigureDialog({ setFileContent={setFileContent} fileType={fileType} setFileType={setFileType} + filePrivacy={filePrivacy} + setFilePrivacy={setFilePrivacy} + isLibraryFile={isLibraryFile} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={libraryAssetPath} + setLibraryAssetPath={setLibraryAssetPath} /> , name: string, @@ -111,30 +134,44 @@ export const saveChanges = async ( await handleFileUpdate(file, digitalTwin, dispatch); } + for (const file of modifiedLibraryFiles) { + await handleFileUpdate(file, digitalTwin, dispatch); + } + showSuccessSnackbar(dispatch, name); dispatch(removeAllModifiedFiles()); + dispatch(removeAllModifiedLibraryFiles()); }; export const handleFileUpdate = async ( - file: FileState, + file: FileState | LibraryConfigFile, digitalTwin: DigitalTwin, dispatch: ReturnType, ) => { try { - await digitalTwin.DTAssets.updateFileContent(file.name, file.content); - - if (file.name === 'description.md') { - dispatch( - updateDescription({ - assetName: digitalTwin.DTName, - description: file.content, - }), + if ('assetPath' in file) { + await digitalTwin.DTAssets.updateLibraryFileContent( + file.fileName, + file.fileContent, + file.assetPath, ); + } else { + await digitalTwin.DTAssets.updateFileContent(file.name, file.content); + + if (file.name === 'description.md') { + dispatch( + updateDescription({ + assetName: digitalTwin.DTName, + description: file.content, + }), + ); + } } } catch (error) { + const fileName = 'assetPath' in file ? file.fileName : file.name; dispatch( showSnackbar({ - message: `Error updating file ${file.name}: ${error}`, + message: `Error updating file ${fileName}: ${error}`, severity: 'error', }), ); @@ -165,6 +202,12 @@ const ReconfigureMainDialog = ({ setFileContent, fileType, setFileType, + filePrivacy, + setFilePrivacy, + isLibraryFile, + setIsLibraryFile, + libraryAssetPath, + setLibraryAssetPath, }: { showDialog: boolean; setShowDialog: Dispatch>; @@ -177,6 +220,12 @@ const ReconfigureMainDialog = ({ setFileContent: Dispatch>; fileType: string; setFileType: Dispatch>; + filePrivacy: string; + setFilePrivacy: Dispatch>; + isLibraryFile: boolean; + setIsLibraryFile: Dispatch>; + libraryAssetPath: string; + setLibraryAssetPath: Dispatch>; }) => ( diff --git a/client/src/preview/route/library/LibraryPreview.tsx b/client/src/preview/route/library/LibraryPreview.tsx new file mode 100644 index 000000000..2e01d91e5 --- /dev/null +++ b/client/src/preview/route/library/LibraryPreview.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import Layout from 'page/Layout'; +import TabComponent from 'components/tab/TabComponent'; +import { Paper, Typography } from '@mui/material'; +import ShoppingCart from 'preview/components/cart/ShoppingCart'; +import AssetLibrary from 'preview/components/asset/AssetLibrary'; +import { assetType, scope } from './LibraryTabDataPreview'; + +export function createTabs() { + return assetType.map((tab) => ({ + label: tab.label, + body: ( + <> + {tab.body} + + ), + })); +} + +export function createCombinedTabs() { + return assetType.map((tab) => + scope.map((subtab) => ({ + label: `${subtab.label}`, + body: ( +
    +
    + {subtab.body} + +
    + + Selection + + +
    + ), + })), + ); +} + +function LibraryContent() { + const tabsData = createTabs(); + const combinedData = createCombinedTabs(); + + return ( + + + + ); +} + +export default function LibraryPreview() { + return ; +} diff --git a/client/src/preview/route/library/LibraryTabDataPreview.ts b/client/src/preview/route/library/LibraryTabDataPreview.ts new file mode 100644 index 000000000..ac82d08cc --- /dev/null +++ b/client/src/preview/route/library/LibraryTabDataPreview.ts @@ -0,0 +1,40 @@ +import { ITabs } from 'route/IData'; + +export const assetType: ITabs[] = [ + { + label: 'Functions', + body: `The functions responsible for pre- and post-processing of: data inputs, data outputs, control outputs. The data science libraries and functions can be used to create useful function assets for the platform. + In some cases, Digital Twin models require calibration prior to their use; functions written by domain experts along with right data inputs can make model calibration an achievable goal. Another use of functions is to process the sensor and actuator data of both Physical Twins and Digital Twins.`, + }, + { + label: 'Models', + body: `The model assets are used to describe different aspects of Physical Twins and their environment, at different levels of abstraction. Therefore, it is possible to have multiple models for the same Physical Twin. For example, a flexible robot used in a car production plant may have structural model(s) which will be useful in tracking the wear and tear of parts. The same robot can have a behavioural model(s) describing the safety guarantees provided by the robot manufacturer. The same robot can also have a functional model(s) describing the part manufacturing capabilities of the robot.`, + }, + { + label: 'Tools', + body: `The software tool assets are software used to create, evaluate and analyze models. These tools are executed on top of a computing platforms, i.e., an operating system, or virtual machines like Java virtual machine, or inside docker containers. The tools tend to be platform specific, making them less reusable than models. + A tool can be packaged to run on a local or distributed virtual machine environments thus allowing selection of most suitable execution environment for a Digital Twin. + Most models require tools to evaluate them in the context of data inputs. + There exist cases where executable packages are run as binaries in a computing environment. Each of these packages are a pre-packaged combination of models and tools put together to create a ready to use Digital Twins.`, + }, + { + label: 'Data', + body: `The data sources and sinks available to a digital twins. Typical examples of data sources are sensor measurements from Physical Twins, and test data provided by manufacturers for calibration of models. Typical examples of data sinks are visualization software, external users and data storage services. There exist special outputs such as events, and commands which are akin to control outputs from a Digital Twin. These control outputs usually go to Physical Twins, but they can also go to another Digital Twin.`, + }, + { + label: 'Digital Twins', + body: `These are ready to use digital twins created by one or more users. These digital twins can be reconfigured later for specific use cases.`, + }, +]; + +// This type of Array Tabs is for the second line of Tabs +export const scope: ITabs[] = [ + { + label: 'Private', + body: `These reusable assets are only visible to you. Other users can not use these assets in their digital twins.`, + }, + { + label: 'Common', + body: `These reusable assets are visible to all users. Other users can use these assets in their digital twins.`, + }, +]; diff --git a/client/src/preview/store/CartAccess.ts b/client/src/preview/store/CartAccess.ts new file mode 100644 index 000000000..043d552be --- /dev/null +++ b/client/src/preview/store/CartAccess.ts @@ -0,0 +1,18 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import LibraryAsset from 'preview/util/libraryAsset'; +import * as cart from './cart.slice'; + +function useCart() { + const dispatch = useDispatch(); + const state = useSelector((store: RootState) => store.cart); + const actions = { + add: (asset: LibraryAsset) => dispatch(cart.addToCart(asset)), + remove: (asset: LibraryAsset) => dispatch(cart.removeFromCart(asset)), + clear: () => dispatch(cart.clearCart()), + }; + + return { state, actions }; +} + +export default useCart; diff --git a/client/src/preview/store/assets.slice.ts b/client/src/preview/store/assets.slice.ts index 085403f20..e54d148cf 100644 --- a/client/src/preview/store/assets.slice.ts +++ b/client/src/preview/store/assets.slice.ts @@ -1,8 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Asset } from '../components/asset/Asset'; +import { RootState } from 'store/store'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { createSelector } from 'reselect'; interface AssetsState { - items: Asset[]; + items: LibraryAsset[]; } const initialState: AssetsState = { @@ -13,17 +15,45 @@ const assetsSlice = createSlice({ name: 'assets', initialState, reducers: { - setAssets: (state, action: PayloadAction) => { + setAssets: (state, action: PayloadAction) => { state.items = action.payload; }, + setAsset: (state, action: PayloadAction) => { + const existingAsset = state.items.find( + (asset) => + asset.path === action.payload.path && + asset.isPrivate === action.payload.isPrivate, + ); + if (!existingAsset) { + state.items.push(action.payload); + } + }, deleteAsset: (state, action: PayloadAction) => { state.items = state.items.filter( - (asset) => asset.path !== action.payload, + (asset) => asset.path !== action.payload && asset.isPrivate === true, ); }, }, }); -export const { setAssets, deleteAsset } = assetsSlice.actions; +export const selectAssetsByTypeAndPrivacy = ( + type: string, + isPrivate: boolean, +) => + createSelector( + (state: RootState) => state.assets.items, + (items: LibraryAsset[]) => + items.filter( + (item) => item.type === type && item.isPrivate === isPrivate, + ), + ); + +export const selectAssetByPathAndPrivacy = + (path: string, isPrivate: boolean) => (state: RootState) => + state.assets.items.find( + (asset) => asset.path === path && asset.isPrivate === isPrivate, + ); + +export const { setAssets, setAsset, deleteAsset } = assetsSlice.actions; export default assetsSlice.reducer; diff --git a/client/src/preview/store/cart.slice.ts b/client/src/preview/store/cart.slice.ts new file mode 100644 index 000000000..30e07ba66 --- /dev/null +++ b/client/src/preview/store/cart.slice.ts @@ -0,0 +1,43 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import LibraryAsset from 'preview/util/libraryAsset'; + +export interface CartState { + assets: LibraryAsset[]; +} + +const initState: CartState = { + assets: [], +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState: initState, + reducers: { + addToCart: (state, action: PayloadAction) => { + if ( + !state.assets.find( + (asset) => + asset.path === action.payload.path && + asset.isPrivate === action.payload.isPrivate, + ) + ) { + state.assets.push(action.payload); + } + }, + removeFromCart: (state, action: PayloadAction) => { + state.assets = state.assets.filter( + (a) => + !( + a.path === action.payload.path && + a.isPrivate === action.payload.isPrivate + ), + ); + }, + clearCart: (state) => { + state.assets = []; + }, + }, +}); + +export const { addToCart, removeFromCart, clearCart } = cartSlice.actions; +export default cartSlice.reducer; diff --git a/client/src/preview/store/digitalTwin.slice.ts b/client/src/preview/store/digitalTwin.slice.ts index 0a6465e7e..e1496ef23 100644 --- a/client/src/preview/store/digitalTwin.slice.ts +++ b/client/src/preview/store/digitalTwin.slice.ts @@ -7,7 +7,15 @@ interface DigitalTwinState { [key: string]: DigitalTwin; } -const initialState: DigitalTwinState = {}; +interface DigitalTwinSliceState { + digitalTwin: DigitalTwinState; + shouldFetchDigitalTwins: boolean; +} + +const initialState: DigitalTwinSliceState = { + digitalTwin: {}, + shouldFetchDigitalTwins: true, +}; const digitalTwinSlice = createSlice({ name: 'digitalTwin', @@ -17,13 +25,13 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; digitalTwin: DigitalTwin }>, ) => { - state[action.payload.assetName] = action.payload.digitalTwin; + state.digitalTwin[action.payload.assetName] = action.payload.digitalTwin; }, setJobLogs: ( state, action: PayloadAction<{ assetName: string; jobLogs: JobLog[] }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.jobLogs = action.payload.jobLogs; } @@ -32,7 +40,7 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; pipelineCompleted: boolean }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.pipelineCompleted = action.payload.pipelineCompleted; } @@ -41,7 +49,7 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; pipelineLoading: boolean }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.pipelineLoading = action.payload.pipelineLoading; } @@ -50,16 +58,22 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; description: string }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.description = action.payload.description; } }, + setShouldFetchDigitalTwins: (state, action: PayloadAction) => { + state.shouldFetchDigitalTwins = action.payload; + }, }, }); export const selectDigitalTwinByName = (name: string) => (state: RootState) => - state.digitalTwin[name]; + state.digitalTwin.digitalTwin[name]; + +export const selectShouldFetchDigitalTwins = (state: RootState) => + state.digitalTwin.shouldFetchDigitalTwins; export const { setDigitalTwin, @@ -67,5 +81,7 @@ export const { setPipelineCompleted, setPipelineLoading, updateDescription, + setShouldFetchDigitalTwins, } = digitalTwinSlice.actions; + export default digitalTwinSlice.reducer; diff --git a/client/src/preview/store/file.slice.ts b/client/src/preview/store/file.slice.ts index 6b047f08f..cbce0bf1b 100644 --- a/client/src/preview/store/file.slice.ts +++ b/client/src/preview/store/file.slice.ts @@ -7,6 +7,7 @@ export interface FileState { isNew: boolean; isModified: boolean; type?: string; + isFromCommonLibrary?: boolean; } const initialState: FileState[] = []; diff --git a/client/src/preview/store/libraryConfigFiles.slice.ts b/client/src/preview/store/libraryConfigFiles.slice.ts new file mode 100644 index 000000000..b58dffac4 --- /dev/null +++ b/client/src/preview/store/libraryConfigFiles.slice.ts @@ -0,0 +1,84 @@ +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootState } from 'store/store'; + +export interface LibraryConfigFile { + assetPath: string; + fileName: string; + fileContent: string; + isNew: boolean; + isModified: boolean; + isPrivate: boolean; +} + +const initialState: LibraryConfigFile[] = []; + +const libraryFilesSlice = createSlice({ + name: 'libraryConfigFiles', + initialState, + reducers: { + addOrUpdateLibraryFile: ( + state, + action: PayloadAction, + ) => { + const { fileName, assetPath, isNew, isPrivate, ...rest } = action.payload; + + if (!fileName || !assetPath) return; + + const index = state.findIndex( + (file) => + file.fileName === fileName && + file.assetPath === assetPath && + file.isNew === isNew && + file.isPrivate === isPrivate, + ); + + if (index >= 0) { + state[index] = { + ...state[index], + ...rest, + isModified: true, + isNew, + }; + } else { + state.push({ + fileName, + assetPath, + ...rest, + isModified: false, + isNew, + isPrivate, + }); + } + }, + + removeAllFiles: (state) => { + state.splice(0, state.length); + }, + + removeAllModifiedLibraryFiles: (state) => { + const filesToSave = state.filter( + (file) => file.isModified && !file.isNew, + ); + filesToSave.forEach((file) => { + const index = state.findIndex( + (f) => f.fileName === file.fileName && !f.isNew, + ); + if (index >= 0) { + state.splice(index, 1); + } + }); + }, + }, +}); + +export const selectModifiedLibraryFiles = createSelector( + (state: RootState) => state.libraryConfigFiles, + (files) => files.filter((file) => !file.isNew), +); + +export const { + addOrUpdateLibraryFile, + removeAllFiles, + removeAllModifiedLibraryFiles, +} = libraryFilesSlice.actions; +export default libraryFilesSlice.reducer; diff --git a/client/src/preview/util/DTAssets.ts b/client/src/preview/util/DTAssets.ts index 9bdf5c6f3..241522e43 100644 --- a/client/src/preview/util/DTAssets.ts +++ b/client/src/preview/util/DTAssets.ts @@ -33,19 +33,73 @@ class DTAssets { } async createFiles( - files: FileState[], + files: + | FileState[] + | Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }>, mainFolderPath: string, lifecycleFolderPath: string, ): Promise { for (const file of files) { + const fileType = (file as FileState).type || 'asset'; + if (file.isNew) { - const filePath = getFilePath(file, mainFolderPath, lifecycleFolderPath); - const commitMessage = `Add ${file.name} to ${file.type === 'lifecycle' ? 'lifecycle' : 'digital twin'} folder`; + const mainFolderPathUpdated = file.isFromCommonLibrary + ? `${mainFolderPath}/common` + : mainFolderPath; + const lifecycleFolderPathUpdated = file.isFromCommonLibrary + ? `${mainFolderPathUpdated}/lifecycle` + : lifecycleFolderPath; + const filePath = + fileType === 'lifecycle' + ? lifecycleFolderPathUpdated + : mainFolderPathUpdated; + const commitMessage = `Add ${file.name} to ${fileType} folder`; await this.fileHandler.createFile(file, filePath, commitMessage); } } } + async getFilesFromAsset(assetPath: string, isPrivate: boolean) { + try { + const fileNames = await this.fileHandler.getLibraryFileNames( + assetPath, + isPrivate, + ); + + const files: Array<{ + name: string; + content: string; + path: string; + isPrivate: boolean; + }> = []; + + for (const fileName of fileNames) { + const fileContent = await this.fileHandler.getFileContent( + `${assetPath}/${fileName}`, + isPrivate, + ); + + files.push({ + name: fileName, + content: fileContent, + path: assetPath, + isPrivate, + }); + } + + return files; + } catch (error) { + throw new Error( + `Error fetching files from asset at ${assetPath}: ${error}`, + ); + } + } + async updateFileContent( fileName: string, fileContent: string, @@ -61,6 +115,17 @@ class DTAssets { await this.fileHandler.updateFile(filePath, fileContent, commitMessage); } + async updateLibraryFileContent( + fileName: string, + fileContent: string, + assetPath: string, + ): Promise { + const filePath = `${assetPath}/${fileName}`; + const commitMessage = `Update ${fileName} content`; + + await this.fileHandler.updateFile(filePath, fileContent, commitMessage); + } + async appendTriggerToPipeline(): Promise { const filePath = `.gitlab-ci.yml`; @@ -131,6 +196,12 @@ ${triggerKey}: async delete(): Promise { await this.removeTriggerFromPipeline(); await this.fileHandler.deleteDT(`digital_twins/${this.DTName}`); + + const libraryDTs = + await this.fileHandler.getFolders(`common/digital_twins`); + if (libraryDTs.includes(`common/digital_twins/${this.DTName}`)) { + await this.fileHandler.deleteDT(`common/digital_twins/${this.DTName}`); + } } async getFileContent(fileName: string): Promise { @@ -145,9 +216,25 @@ ${triggerKey}: return fileContent; } + async getLibraryFileContent( + assetPath: string, + fileName: string, + ): Promise { + const filePath = `${assetPath}/${fileName}`; + return this.fileHandler.getFileContent(filePath); + } + async getFileNames(fileType: FileType): Promise { return this.fileHandler.getFileNames(fileType); } + + async getLibraryConfigFileNames(filePath: string): Promise { + return this.fileHandler.getLibraryConfigFileNames(filePath, true); + } + + async getFolders(path: string): Promise { + return this.fileHandler.getFolders(path); + } } export default DTAssets; diff --git a/client/src/preview/util/digitalTwin.ts b/client/src/preview/util/digitalTwin.ts index 15910fbbf..74f129f4e 100644 --- a/client/src/preview/util/digitalTwin.ts +++ b/client/src/preview/util/digitalTwin.ts @@ -1,8 +1,18 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + import { getAuthority } from 'util/envUtil'; import { FileState } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import GitlabInstance from './gitlab'; -import { isValidInstance, logError, logSuccess } from './digitalTwinUtils'; +import { + isValidInstance, + logError, + logSuccess, + getUpdatedLibraryFile, +} from './digitalTwinUtils'; import DTAssets, { FileType } from './DTAssets'; +import LibraryAsset from './libraryAsset'; const RUNNER_TAG = 'linux'; @@ -36,6 +46,8 @@ class DigitalTwin { public lifecycleFiles: string[] = []; + public assetFiles: { assetPath: string; fileNames: string[] }[] = []; + constructor(DTName: string, gitlabInstance: GitlabInstance) { this.DTName = DTName; this.gitlabInstance = gitlabInstance; @@ -123,7 +135,11 @@ class DigitalTwin { } } - async create(files: FileState[]): Promise { + async create( + files: FileState[], + cartAssets: LibraryAsset[], + libraryFiles: LibraryConfigFile[], + ): Promise { if (!this.gitlabInstance.projectId) { return `Error creating ${this.DTName} digital twin: no project id`; } @@ -132,15 +148,30 @@ class DigitalTwin { const lifecycleFolderPath = `${mainFolderPath}/lifecycle`; try { + const assetFilesToCreate = await this.prepareAllAssetFiles( + cartAssets, + libraryFiles, + ); + await this.DTAssets.createFiles( files, mainFolderPath, lifecycleFolderPath, ); + + await this.DTAssets.createFiles( + assetFilesToCreate, + mainFolderPath, + lifecycleFolderPath, + ); + await this.DTAssets.appendTriggerToPipeline(); + return `${this.DTName} digital twin files initialized successfully.`; } catch (error) { - return `Error initializing ${this.DTName} digital twin files: ${String(error)}`; + return `Error initializing ${this.DTName} digital twin files: ${String( + error, + )}`; } } @@ -170,6 +201,90 @@ class DigitalTwin { async getLifecycleFiles() { this.lifecycleFiles = await this.DTAssets.getFileNames(FileType.LIFECYCLE); } + + async prepareAllAssetFiles( + cartAssets: LibraryAsset[], + libraryFiles: LibraryConfigFile[], + ): Promise< + Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }> + > { + const assetFilesToCreate: Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }> = []; + + for (const asset of cartAssets) { + const assetFiles = await this.DTAssets.getFilesFromAsset( + asset.path, + asset.isPrivate, + ); + for (const assetFile of assetFiles) { + const updatedFile = getUpdatedLibraryFile( + assetFile.name, + asset.path, + asset.isPrivate, + libraryFiles, + ); + + assetFilesToCreate.push({ + name: `${asset.name}/${assetFile.name}`, + content: updatedFile ? updatedFile.fileContent : assetFile.content, + isNew: true, + isFromCommonLibrary: !asset.isPrivate, + }); + } + } + return assetFilesToCreate; + } + + async getAssetFiles(): Promise<{ assetPath: string; fileNames: string[] }[]> { + const mainFolderPath = `digital_twins/${this.DTName}`; + const excludeFolder = 'lifecycle'; + const result: { assetPath: string; fileNames: string[] }[] = []; + + try { + const folders = await this.DTAssets.getFolders(mainFolderPath); + + const validFolders = folders.filter( + (folder) => !folder.includes(excludeFolder), + ); + + for (const folder of validFolders) { + if (folder.endsWith('/common')) { + const subFolders = await this.DTAssets.getFolders(folder); + for (const subFolder of subFolders) { + const fileNames = + await this.DTAssets.getLibraryConfigFileNames(subFolder); + + result.push({ + assetPath: subFolder, + fileNames, + }); + } + } else { + const fileNames = + await this.DTAssets.getLibraryConfigFileNames(folder); + + result.push({ + assetPath: folder, + fileNames, + }); + } + } + + this.assetFiles = result; + } catch (_error) { + return []; + } + return result; + } } export default DigitalTwin; diff --git a/client/src/preview/util/digitalTwinUtils.ts b/client/src/preview/util/digitalTwinUtils.ts index 02b61b46b..409a429a9 100644 --- a/client/src/preview/util/digitalTwinUtils.ts +++ b/client/src/preview/util/digitalTwinUtils.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import DigitalTwin from './digitalTwin'; export function isValidInstance(digitalTwin: DigitalTwin): boolean { @@ -31,3 +32,20 @@ export function logError( }); digitalTwin.lastExecutionStatus = 'error'; } + +export function getUpdatedLibraryFile( + fileName: string, + assetPath: string, + isPrivate: boolean, + libraryFiles: LibraryConfigFile[], +): LibraryConfigFile | null { + return ( + libraryFiles.find( + (libFile) => + libFile.fileName === fileName && + libFile.assetPath === assetPath && + libFile.isPrivate === isPrivate && + libFile.isModified, + ) || null + ); +} diff --git a/client/src/preview/util/fileHandler.ts b/client/src/preview/util/fileHandler.ts index 5970d02d5..b5f661c14 100644 --- a/client/src/preview/util/fileHandler.ts +++ b/client/src/preview/util/fileHandler.ts @@ -1,8 +1,13 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + import { FileState } from 'preview/store/file.slice'; import GitlabInstance from './gitlab'; import { IFile } from './ifile'; import { FileType } from './DTAssets'; +const COMMON_LIBRARY_PROJECT_ID = 3; + export function isValidFileType( item: { type: string; name: string; path: string }, fileType: FileType, @@ -18,23 +23,32 @@ export function isValidFileType( return typeChecks[fileType]; } +export function isImageFile(fileName: string): boolean { + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg']; + return imageExtensions.some((ext) => fileName.toLowerCase().endsWith(ext)); +} + class FileHandler implements IFile { - public DTName: string; + public name: string; public gitlabInstance: GitlabInstance; - constructor(DTName: string, gitlabInstance: GitlabInstance) { - this.DTName = DTName; + constructor(name: string, gitlabInstance: GitlabInstance) { + this.name = name; this.gitlabInstance = gitlabInstance; } async createFile( - file: FileState, + file: FileState | { name: string; content: string; isNew: boolean }, filePath: string, commitMessage: string, + commonProject?: boolean, ): Promise { + const projectToUse = commonProject + ? COMMON_LIBRARY_PROJECT_ID + : this.gitlabInstance.projectId; await this.gitlabInstance.api.RepositoryFiles.create( - this.gitlabInstance.projectId!, + projectToUse!, `${filePath}/${file.name}`, 'main', file.content, @@ -61,13 +75,18 @@ class FileHandler implements IFile { this.gitlabInstance.projectId!, digitalTwinPath, 'main', - `Removing ${this.DTName} digital twin`, + `Removing ${this.name} digital twin`, ); } - async getFileContent(filePath: string): Promise { + async getFileContent(filePath: string, isPrivate?: boolean): Promise { + const projectToUse = + isPrivate === false + ? COMMON_LIBRARY_PROJECT_ID + : this.gitlabInstance.projectId; + const response = await this.gitlabInstance.api.RepositoryFiles.show( - this.gitlabInstance.projectId!, + projectToUse!, filePath, 'main', ); @@ -76,9 +95,9 @@ class FileHandler implements IFile { async getFileNames(fileType: FileType): Promise { const pathMap = { - [FileType.DESCRIPTION]: `digital_twins/${this.DTName}`, - [FileType.CONFIGURATION]: `digital_twins/${this.DTName}`, - [FileType.LIFECYCLE]: `digital_twins/${this.DTName}/lifecycle`, + [FileType.DESCRIPTION]: `digital_twins/${this.name}`, + [FileType.CONFIGURATION]: `digital_twins/${this.name}`, + [FileType.LIFECYCLE]: `digital_twins/${this.name}/lifecycle`, }; try { @@ -98,6 +117,89 @@ class FileHandler implements IFile { return []; } } + + async getLibraryFileNames( + filePath: string, + isPrivate: boolean, + ): Promise { + const projectToUse = isPrivate + ? this.gitlabInstance.projectId + : COMMON_LIBRARY_PROJECT_ID; + + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + projectToUse!, + { + path: filePath, + recursive: false, + }, + ); + + const fileNames: string[] = []; + for (const file of response) { + if (file.type === 'tree') { + const nestedFiles = await this.getLibraryFileNames( + `${filePath}/${file.name}`, + isPrivate, + ); + fileNames.push( + ...nestedFiles.map((nestedFile) => `${file.name}/${nestedFile}`), + ); + } else if (!isImageFile(file.name) && !file.name.endsWith('.fmu')) { + fileNames.push(file.name); + } + } + + return fileNames; + } catch { + return []; + } + } + + async getLibraryConfigFileNames( + filePath: string, + isPrivate: boolean, + ): Promise { + const projectToUse = isPrivate + ? this.gitlabInstance.projectId + : COMMON_LIBRARY_PROJECT_ID; + + const shouldBeRecursive = filePath.includes('common/'); + + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + projectToUse!, + { + path: filePath, + recursive: shouldBeRecursive, + }, + ); + + return response + .filter((item) => isValidFileType(item, FileType.CONFIGURATION)) + .map((file) => file.name); + } catch (_error) { + return []; + } + } + + async getFolders(path: string): Promise { + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + this.gitlabInstance.projectId!, + { path, recursive: false }, + ); + + return response + .filter((item: { type: string }) => item.type === 'tree') + .map((folder: { path: string }) => folder.path); + } catch (_error) { + return []; + } + } } export default FileHandler; diff --git a/client/src/preview/util/fileUtils.ts b/client/src/preview/util/fileUtils.ts index 8330eed7c..61afaefcd 100644 --- a/client/src/preview/util/fileUtils.ts +++ b/client/src/preview/util/fileUtils.ts @@ -3,6 +3,7 @@ import { FileState, renameFile, } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import { Dispatch, SetStateAction } from 'react'; import { useDispatch } from 'react-redux'; @@ -24,15 +25,28 @@ export const getExtension = (filename: string): string => { export const validateFiles = ( files: FileState[], + libraryFiles: LibraryConfigFile[], setErrorMessage: Dispatch>, ): boolean => { const emptyFiles = files .filter((file) => file.isNew && file.content === '') .map((file) => file.name); - if (emptyFiles.length > 0) { + const emptyLibraryFiles = libraryFiles.filter( + (file) => file.isNew && file.isModified && file.fileContent === '', + ); + + if (emptyFiles.length > 0 || emptyLibraryFiles.length > 0) { setErrorMessage( - `The following files have empty content: ${emptyFiles.join(', ')}. Edit them in order to create the new digital twin.`, + `The following files have empty content: ${ + emptyFiles.length > 0 ? emptyFiles.join(', ') : '' + }${emptyFiles.length > 0 && emptyLibraryFiles.length > 0 ? ', ' : ''}${ + emptyLibraryFiles.length > 0 + ? emptyLibraryFiles + .map((file) => `${file.fileName} (${file.assetPath})`) + .join(', ') + : '' + }.\n Edit them in order to create the new digital twin.`, ); return true; } @@ -92,3 +106,33 @@ export const handleChangeFileName = ( setOpenChangeFileNameDialog(false); }; + +export const getFileTypeFromExtension = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + if (extension === 'md') return 'description'; + if (extension === 'json' || extension === 'yaml' || extension === 'yml') + return 'config'; + return 'lifecycle'; +}; + +export const getFilteredFileNames = (type: string, files: FileState[]) => + files + .filter( + (file) => file.isNew && getFileTypeFromExtension(file.name) === type, + ) + .map((file) => file.name); + +export const updateFileState = ( + fileName: string, + fileContent: string, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + isPrivate?: boolean, +) => { + setFileName(fileName); + setFileContent(fileContent); + setFileType(fileName.split('.').pop()!); + setFilePrivacy(isPrivate === undefined || isPrivate ? 'private' : 'common'); +}; diff --git a/client/src/preview/util/gitlab.ts b/client/src/preview/util/gitlab.ts index e558782bb..368356db6 100644 --- a/client/src/preview/util/gitlab.ts +++ b/client/src/preview/util/gitlab.ts @@ -3,6 +3,24 @@ import { Asset } from '../components/asset/Asset'; const GROUP_NAME = 'DTaaS'; const DT_DIRECTORY = 'digital_twins'; +const COMMON_LIBRARY_PROJECT_ID = 3; + +export function mapStringToAssetPath(type: string): string | undefined { + switch (type) { + case 'Functions': + return 'functions'; + case 'Models': + return 'models'; + case 'Tools': + return 'tools'; + case 'Data': + return 'data'; + case 'Digital Twins': + return 'digital_twins'; + default: + return undefined; + } +} interface LogEntry { status: string; @@ -18,8 +36,6 @@ class GitlabInstance { public logs: LogEntry[]; - public subfolders: Asset[]; - public projectId: number | null = null; public triggerToken: string | null = null; @@ -31,7 +47,6 @@ class GitlabInstance { oauthToken, }); this.logs = []; - this.subfolders = []; } async init() { @@ -80,10 +95,41 @@ class GitlabInstance { .map(async (file) => ({ name: file.name, path: file.path, + type: 'digitalTwin', + isPrivate: true, + })), + ); + return subfolders; + } + + async getLibrarySubfolders( + projectId: number, + type: string, + isPrivate: boolean, + ): Promise { + const mappedPath = mapStringToAssetPath(type); + if (!mappedPath) { + throw new Error(`Invalid asset type: ${type}`); + } + + const projectToUse = isPrivate ? projectId : COMMON_LIBRARY_PROJECT_ID; + + const files = await this.api.Repositories.allRepositoryTrees(projectToUse, { + path: mappedPath, + recursive: false, + }); + + const subfolders: Asset[] = await Promise.all( + files + .filter((file) => file.type === 'tree' && file.path !== mappedPath) + .map(async (file) => ({ + name: file.name, + path: file.path, + type, + isPrivate, })), ); - this.subfolders = subfolders; return subfolders; } diff --git a/client/src/preview/util/init.ts b/client/src/preview/util/init.ts index 534849065..382417b0f 100644 --- a/client/src/preview/util/init.ts +++ b/client/src/preview/util/init.ts @@ -3,32 +3,55 @@ import { useDispatch } from 'react-redux'; import { getAuthority } from 'util/envUtil'; import GitlabInstance from './gitlab'; import DigitalTwin from './digitalTwin'; -import { setAssets } from '../store/assets.slice'; +import { setAsset, setAssets } from '../store/assets.slice'; import { setDigitalTwin } from '../store/digitalTwin.slice'; +import LibraryAsset from './libraryAsset'; -const gitlabInstance = new GitlabInstance( +const initialGitlabInstance = new GitlabInstance( sessionStorage.getItem('username') || '', getAuthority(), sessionStorage.getItem('access_token') || '', ); -export const fetchAssets = async ( +function createGitlabInstance(): GitlabInstance { + const username = sessionStorage.getItem('username') || ''; + const authority = getAuthority(); + const accessToken = sessionStorage.getItem('access_token') || ''; + + return new GitlabInstance(username, authority, accessToken); +} + +export const fetchLibraryAssets = async ( dispatch: ReturnType, setError: Dispatch>, + type: string, + isPrivate: boolean, ) => { try { - await gitlabInstance.init(); - if (gitlabInstance.projectId) { - const subfolders = await gitlabInstance.getDTSubfolders( - gitlabInstance.projectId, + await initialGitlabInstance.init(); + if (initialGitlabInstance.projectId) { + const subfolders = await initialGitlabInstance.getLibrarySubfolders( + initialGitlabInstance.projectId, + type, + isPrivate, ); - dispatch(setAssets(subfolders)); - subfolders.forEach(async (asset) => { - const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); - await digitalTwin.getDescription(); - dispatch(setDigitalTwin({ assetName: asset.name, digitalTwin })); - }); + const assets = await Promise.all( + subfolders.map(async (subfolder) => { + const gitlabInstance = createGitlabInstance(); + await gitlabInstance.init(); + const libraryAsset = new LibraryAsset( + subfolder.name, + subfolder.path, + isPrivate, + type, + gitlabInstance, + ); + await libraryAsset.getDescription(); + return libraryAsset; + }), + ); + assets.forEach((asset) => dispatch(setAsset(asset))); } else { dispatch(setAssets([])); } @@ -37,6 +60,39 @@ export const fetchAssets = async ( } }; +export const fetchDigitalTwins = async ( + dispatch: ReturnType, + setError: Dispatch>, +) => { + try { + await initialGitlabInstance.init(); + + if (initialGitlabInstance.projectId) { + const subfolders = await initialGitlabInstance.getDTSubfolders( + initialGitlabInstance.projectId, + ); + + await fetchLibraryAssets(dispatch, setError, 'Digital Twins', true); + + const digitalTwins = await Promise.all( + subfolders.map(async (asset) => { + const gitlabInstance = createGitlabInstance(); + await gitlabInstance.init(); + const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); + await digitalTwin.getDescription(); + return { assetName: asset.name, digitalTwin }; + }), + ); + + digitalTwins.forEach(({ assetName, digitalTwin }) => + dispatch(setDigitalTwin({ assetName, digitalTwin })), + ); + } + } catch (err) { + setError(`An error occurred while fetching assets: ${err}`); + } +}; + export async function initDigitalTwin( newDigitalTwinName: string, ): Promise { diff --git a/client/src/preview/util/libraryAsset.ts b/client/src/preview/util/libraryAsset.ts new file mode 100644 index 000000000..1191dd6b5 --- /dev/null +++ b/client/src/preview/util/libraryAsset.ts @@ -0,0 +1,86 @@ +import { getAuthority } from 'util/envUtil'; +import GitlabInstance from './gitlab'; +import LibraryManager from './libraryManager'; + +class LibraryAsset { + public name: string; + + public path: string; + + public type: string; + + public isPrivate: boolean; + + public gitlabInstance: GitlabInstance; + + public description: string = ''; + + public fullDescription: string = ''; + + public libraryManager: LibraryManager; + + public configFiles: string[] = []; + + constructor( + name: string, + path: string, + isPrivate: boolean, + type: string, + gitlabInstance: GitlabInstance, + ) { + this.name = name; + this.path = path; + this.isPrivate = isPrivate; + this.type = type; + this.gitlabInstance = gitlabInstance; + this.libraryManager = new LibraryManager(name, this.gitlabInstance); + } + + async getDescription(): Promise { + if (this.gitlabInstance.projectId) { + try { + const fileContent = await this.libraryManager.getFileContent( + this.isPrivate, + this.path, + 'description.md', + ); + this.description = fileContent; + } catch (_error) { + this.description = `There is no description.md file`; + } + } + } + + async getFullDescription(): Promise { + if (this.gitlabInstance.projectId) { + const imagesPath = this.path; + try { + const fileContent = await this.libraryManager.getFileContent( + this.isPrivate, + this.path, + 'README.md', + ); + this.fullDescription = fileContent.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`; + } + } else { + this.fullDescription = 'Error fetching description, retry.'; + } + } + + async getConfigFiles() { + this.configFiles = await this.libraryManager.getFileNames( + this.isPrivate, + this.path, + ); + } +} + +export default LibraryAsset; diff --git a/client/src/preview/util/libraryManager.ts b/client/src/preview/util/libraryManager.ts new file mode 100644 index 000000000..083298ce7 --- /dev/null +++ b/client/src/preview/util/libraryManager.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + +import { FileState } from 'preview/store/file.slice'; +import GitlabInstance from './gitlab'; +import FileHandler from './fileHandler'; + +export enum FileType { + DESCRIPTION = 'description', + CONFIGURATION = 'configuration', + LIFECYCLE = 'lifecycle', +} + +export function getFilePath( + file: FileState, + mainFolderPath: string, + lifecycleFolderPath: string, +): string { + return file.type === 'lifecycle' ? lifecycleFolderPath : mainFolderPath; +} + +class LibraryManager { + public assetName: string; + + public gitlabInstance: GitlabInstance; + + public fileHandler: FileHandler; + + constructor(assetName: string, gitlabInstance: GitlabInstance) { + this.assetName = assetName; + this.gitlabInstance = gitlabInstance; + this.fileHandler = new FileHandler(assetName, gitlabInstance); + } + + async getFileContent( + isPrivate: boolean, + path: string, + fileName: string, + ): Promise { + const filePath = `${path}/${fileName}`; + + const fileContent = await this.fileHandler.getFileContent( + filePath, + isPrivate, + ); + return fileContent; + } + + async getFileNames(isPrivate: boolean, path: string): Promise { + const fileNames = await this.fileHandler.getLibraryConfigFileNames( + path, + isPrivate, + ); + return fileNames; + } +} + +export default LibraryManager; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 676481ae0..6a28666cd 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; +import LibraryPreview from 'preview/route/library/LibraryPreview'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; @@ -57,6 +58,14 @@ export const routes = [ ), }, + { + path: 'preview/library', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/src/store/store.ts b/client/src/store/store.ts index aa0a904cf..38289311e 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -4,6 +4,8 @@ import digitalTwinSlice from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; import assetsSlice from 'preview/store/assets.slice'; import fileSlice from 'preview/store/file.slice'; +import cartSlice from 'preview/store/cart.slice'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; import menuSlice from './menu.slice'; import authSlice from './auth.slice'; @@ -14,6 +16,8 @@ const rootReducer = combineReducers({ digitalTwin: digitalTwinSlice, snackbar: snackbarSlice, files: fileSlice, + cart: cartSlice, + libraryConfigFiles: libraryConfigFilesSlice, }); const store = configureStore({ diff --git a/client/src/util/envUtil.ts b/client/src/util/envUtil.ts index fa7677850..826578066 100644 --- a/client/src/util/envUtil.ts +++ b/client/src/util/envUtil.ts @@ -63,7 +63,8 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { if (value !== undefined) { const keyWithoutPrefix = key.slice(prefix.length); const linkValue = - keyWithoutPrefix === 'DT_PREVIEW' + keyWithoutPrefix === 'DT_PREVIEW' || + keyWithoutPrefix === 'LIBRARY_PREVIEW' ? value : useUserLink(useAppURL(), value); workbenchLinkValues.push({ diff --git a/client/test/README.md b/client/test/README.md index 0ef073dd4..0bf6e3329 100644 --- a/client/test/README.md +++ b/client/test/README.md @@ -78,6 +78,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', @@ -102,6 +103,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index db9e3de5d..8d9e0d057 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -3,6 +3,7 @@ import GitlabInstance from 'preview/util/gitlab'; import DigitalTwin from 'preview/util/digitalTwin'; import FileHandler from 'preview/util/fileHandler'; import DTAssets from 'preview/util/DTAssets'; +import LibraryManager from 'preview/util/libraryManager'; export const mockAppURL = 'https://example.com/'; export const mockURLforDT = 'https://example.com/URL_DT'; @@ -62,13 +63,13 @@ export const mockGitlabInstance: GitlabInstance = { requesterFn: jest.fn(), }), logs: [], - subfolders: [], projectId: 1, triggerToken: 'mock trigger token', init: jest.fn(), getProjectId: jest.fn(), getTriggerToken: jest.fn(), getDTSubfolders: jest.fn(), + getLibrarySubfolders: jest.fn(), executionLogs: jest.fn(), getPipelineJobs: jest.fn(), getJobTrace: jest.fn(), @@ -76,13 +77,16 @@ export const mockGitlabInstance: GitlabInstance = { }; export const mockFileHandler: FileHandler = { - DTName: 'mockedDTName', + name: 'mockedName', gitlabInstance: mockGitlabInstance, createFile: jest.fn(), updateFile: jest.fn(), deleteDT: jest.fn(), getFileContent: jest.fn(), getFileNames: jest.fn(), + getLibraryFileNames: jest.fn(), + getLibraryConfigFileNames: jest.fn(), + getFolders: jest.fn(), }; export const mockDTAssets: DTAssets = { @@ -90,11 +94,24 @@ export const mockDTAssets: DTAssets = { gitlabInstance: mockGitlabInstance, fileHandler: mockFileHandler, createFiles: jest.fn(), + getFilesFromAsset: jest.fn(), updateFileContent: jest.fn(), + updateLibraryFileContent: jest.fn(), appendTriggerToPipeline: jest.fn(), removeTriggerFromPipeline: jest.fn(), delete: jest.fn(), getFileContent: jest.fn(), + getLibraryFileContent: jest.fn(), + getFileNames: jest.fn(), + getLibraryConfigFileNames: jest.fn(), + getFolders: jest.fn(), +}; + +export const mockLibraryManager: LibraryManager = { + assetName: 'mockedAssetName', + gitlabInstance: mockGitlabInstance, + fileHandler: mockFileHandler, + getFileContent: jest.fn(), getFileNames: jest.fn(), }; @@ -110,21 +127,42 @@ export const mockDigitalTwin: DigitalTwin = { pipelineLoading: false, pipelineCompleted: false, descriptionFiles: ['descriptionFile'], - lifecycleFiles: ['lifecycleFile'], configFiles: ['configFile'], + lifecycleFiles: ['lifecycleFile'], + assetFiles: [ + { assetPath: 'assetPath', fileNames: ['assetFileName1', 'assetFileName2'] }, + ], getDescription: jest.fn(), getFullDescription: jest.fn(), - execute: jest.fn(), triggerPipeline: jest.fn(), + execute: jest.fn(), stop: jest.fn(), create: jest.fn().mockResolvedValue('Success'), delete: jest.fn(), getDescriptionFiles: jest.fn().mockResolvedValue(['descriptionFile']), getLifecycleFiles: jest.fn().mockResolvedValue(['lifecycleFile']), getConfigFiles: jest.fn().mockResolvedValue(['configFile']), + prepareAllAssetFiles: jest.fn(), + getAssetFiles: jest.fn(), } as unknown as DigitalTwin; +export const mockLibraryAsset = { + name: 'Asset 1', + path: 'path', + type: 'Digital Twins', + isPrivate: true, + gitlabInstance: mockGitlabInstance, + description: 'description', + fullDescription: 'fullDescription', + libraryManager: mockLibraryManager, + configFiles: [], + + getDescription: jest.fn(), + getFullDescription: jest.fn(), + getConfigFiles: jest.fn(), +}; + jest.mock('util/envUtil', () => ({ ...jest.requireActual('util/envUtil'), useAppURL: () => mockAppURL, diff --git a/client/test/preview/integration/components/asset/AssetBoard.test.tsx b/client/test/preview/integration/components/asset/AssetBoard.test.tsx index d81452010..736f316ce 100644 --- a/client/test/preview/integration/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/integration/components/asset/AssetBoard.test.tsx @@ -6,27 +6,34 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import assetsReducer, { setAssets } from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, + setShouldFetchDigitalTwins, } from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; -import { Asset } from 'preview/components/asset/Asset'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import fileSlice, { FileState, addOrUpdateFile, } from 'preview/store/file.slice'; import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), })); jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), + fetchDigitalTwins: jest.fn(), })); jest.useFakeTimers(); -const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; +const asset1 = mockLibraryAsset; +asset1.name = 'Asset 1'; +const preSetItems: LibraryAsset[] = [asset1]; const files: FileState[] = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, @@ -38,6 +45,7 @@ const store = configureStore({ digitalTwin: digitalTwinReducer, snackbar: snackbarSlice, files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -55,6 +63,7 @@ describe('AssetBoard Integration Tests', () => { }), ); store.dispatch(addOrUpdateFile(files[0])); + store.dispatch(setShouldFetchDigitalTwins(true)); }; beforeEach(() => { @@ -65,7 +74,7 @@ describe('AssetBoard Integration Tests', () => { jest.clearAllMocks(); }); - it('renders AssetBoard with AssetCardExecute', () => { + it('renders AssetBoard with AssetCardExecute', async () => { act(() => { render( @@ -74,10 +83,14 @@ describe('AssetBoard Integration Tests', () => { ); }); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Asset 1')).toBeInTheDocument(); }); - it('renders AssetBoard with AssetCardManage', () => { + it('renders AssetBoard with AssetCardManage', async () => { act(() => { render( @@ -86,7 +99,9 @@ describe('AssetBoard Integration Tests', () => { ); }); - expect(screen.getByText('Asset 1')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Asset 1')).toBeInTheDocument(); + }); }); it('deletes an asset', async () => { @@ -98,6 +113,10 @@ describe('AssetBoard Integration Tests', () => { ); }); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + const deleteButton = screen.getByRole('button', { name: /Delete/i }); expect(deleteButton).toBeInTheDocument(); @@ -116,19 +135,4 @@ describe('AssetBoard Integration Tests', () => { expect(screen.queryByText('Asset 1')).not.toBeInTheDocument(); }); }); - - it('shows an error message', async () => { - const error = 'An error occurred'; - jest.spyOn(React, 'useState').mockReturnValue([error, jest.fn()]); - - act(() => { - render( - - - , - ); - }); - - expect(screen.getByText(error)).toBeInTheDocument(); - }); }); diff --git a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx index c9ff65206..59cfb893d 100644 --- a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx +++ b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx @@ -2,13 +2,25 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { fireEvent, render, screen, act } from '@testing-library/react'; 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'; +import { Provider, useSelector } from 'react-redux'; +import assetsReducer, { + selectAssetByPathAndPrivacy, + setAssets, +} from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, } from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; -import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { RootState } from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); const store = configureStore({ reducer: combineReducers({ @@ -27,17 +39,23 @@ describe('AssetCardExecute Integration Test', () => { name: 'Asset 1', description: 'Mocked description', path: 'path/asset1', + type: 'Digital twins', + isPrivate: true, }; beforeEach(() => { - store.dispatch( - setAssets([ - { - name: 'Asset 1', - path: 'path/asset1', - }, - ]), + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if ( + selector === selectAssetByPathAndPrivacy(asset.path, asset.isPrivate) + ) { + return null; + } + return mockDigitalTwin; + }, ); + + store.dispatch(setAssets([mockLibraryAsset])); store.dispatch( setDigitalTwin({ assetName: 'Asset 1', @@ -58,15 +76,13 @@ describe('AssetCardExecute Integration Test', () => { jest.clearAllMocks(); }); - it('opens the Snackbar after clicking the Start button', async () => { + it('should start execution', async () => { const startStopButton = screen.getByRole('button', { name: /Start/i }); await act(async () => { fireEvent.click(startStopButton); }); - expect( - screen.getByText('Execution mockedStatus for MockedDTName'), - ).toBeInTheDocument(); + expect(screen.getByText('Stop')).toBeInTheDocument(); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx index 979d927d1..84699ac13 100644 --- a/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import ConfirmDeleteDialog from 'preview/route/digitaltwins/create/ConfirmDeleteDialog'; -import { act, render, screen } from '@testing-library/react'; import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import fileSlice, { addOrUpdateFile } from 'preview/store/file.slice'; +import { act, render, screen } from '@testing-library/react'; const store = configureStore({ reducer: combineReducers({ diff --git a/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx index 8ef4e47dd..ba2814cac 100644 --- a/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx @@ -6,6 +6,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import fileSlice from 'preview/store/file.slice'; import { validateFiles } from 'preview/util/fileUtils'; import { initDigitalTwin } from 'preview/util/init'; +import cartSlice from 'preview/store/cart.slice'; jest.mock('preview/util/fileUtils', () => ({ validateFiles: jest.fn(), @@ -18,6 +19,7 @@ jest.mock('preview/util/init', () => ({ const store = configureStore({ reducer: combineReducers({ files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx b/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx index 94cf56e9f..17c476b27 100644 --- a/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx @@ -12,12 +12,14 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import digitalTwinReducer from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; import fileSlice from 'preview/store/file.slice'; +import cartSlice from 'preview/store/cart.slice'; const store = configureStore({ reducer: combineReducers({ digitalTwin: digitalTwinReducer, snackbar: snackbarSlice, files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx b/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx index 27bc2e8d9..21b592e8a 100644 --- a/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx @@ -14,6 +14,7 @@ describe('FileActionButtons', () => { fileName="testName" setOpenDeleteFileDialog={setOpenDeleteFileDialog} setOpenChangeFileNameDialog={setOpenChangeFileNameDialog} + isLibraryFile={false} />, ); }); @@ -34,7 +35,7 @@ describe('FileActionButtons', () => { it('handles click on change file name button', () => { const changeFileNameButton = screen.getByRole('button', { - name: /Change file name/i, + name: /Rename File/i, }); act(() => { changeFileNameButton.click(); diff --git a/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx index 603d70050..f9f800015 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx @@ -10,11 +10,15 @@ 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/digitalTwin'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import { handleFileClick } from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import LibraryAsset from 'preview/util/libraryAsset'; +import cartSlice, { addToCart } from 'preview/store/cart.slice'; describe('Editor', () => { const fileName = 'file1.md'; @@ -23,8 +27,11 @@ describe('Editor', () => { const setFileName = jest.fn(); const setFileContent = jest.fn(); const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); - const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const preSetItems: LibraryAsset[] = [mockLibraryAsset]; const files = [ { name: 'file1.md', content: 'content1', isNew: false, isModified: false }, ]; @@ -34,6 +41,7 @@ describe('Editor', () => { assets: assetsReducer, digitalTwin: digitalTwinReducer, files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -47,6 +55,7 @@ describe('Editor', () => { digitalTwinInstance.lifecycleFiles = ['lifecycle1.txt', 'lifecycle2.txt']; const setupTest = async () => { + store.dispatch(addToCart(mockLibraryAsset)); store.dispatch(setAssets(preSetItems)); await act(async () => { store.dispatch( @@ -85,6 +94,12 @@ describe('Editor', () => { setFileContent={setFileContent} fileType={fileType} setFileType={setFileType} + filePrivacy={'private'} + setFilePrivacy={setFilePrivacy} + isLibraryFile={false} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={''} + setLibraryAssetPath={setLibraryAssetPath} /> , ); @@ -132,8 +147,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); @@ -159,8 +177,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); @@ -186,8 +207,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); diff --git a/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx index eed912b2d..c6e482708 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx @@ -13,14 +13,21 @@ import { } from '@testing-library/react'; import { Provider } from 'react-redux'; import * as React from 'react'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import DigitalTwin from 'preview/util/digitalTwin'; import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import cartSlice, { addToCart } from 'preview/store/cart.slice'; describe('Sidebar', () => { const setFileNameMock = jest.fn(); const setFileContentMock = jest.fn(); const setFileTypeMock = jest.fn(); + const setFilePrivacyMock = jest.fn(); + const setIsLibraryFileMock = jest.fn(); + const setLibraryAssetPathMock = jest.fn(); let store: ReturnType; let digitalTwin: DigitalTwin; @@ -99,6 +106,7 @@ describe('Sidebar', () => { beforeEach(async () => { store = configureStore({ reducer: combineReducers({ + cart: cartSlice, digitalTwin: digitalTwinReducer, files: fileSlice, }), @@ -108,6 +116,8 @@ describe('Sidebar', () => { }), }); + store.dispatch(addToCart(mockLibraryAsset)); + const files = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, ]; @@ -131,7 +141,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'reconfigure'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -154,7 +169,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -179,7 +199,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -204,7 +229,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); diff --git a/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx index 9d43c4082..9e42595f3 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx @@ -1,142 +1,158 @@ +import { useDispatch } from 'react-redux'; import { - handleFileClick, + handleCreateFileClick, + handleReconfigureFileClick, + handleAddFileClick, + handleCloseFileNameDialog, handleFileSubmit, } from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import DigitalTwin from 'preview/util/digitalTwin'; -import { FileState, addOrUpdateFile } from 'preview/store/file.slice'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; -import { useDispatch } from 'react-redux'; +import { FileState } from 'preview/store/file.slice'; +import { updateFileState } from 'preview/util/fileUtils'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); -describe('File Click Handlers', () => { - const mockSetFileName = jest.fn(); - const mockSetFileContent = jest.fn(); - const mockSetFileType = jest.fn(); +jest.mock('preview/route/digitaltwins/editor/sidebarFetchers', () => ({ + fetchAndSetFileContent: jest.fn(), + fetchAndSetFileLibraryContent: jest.fn(), +})); - const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +jest.mock('preview/util/fileUtils', () => ({ + updateFileState: jest.fn(), + getFileTypeFromExtension: jest.fn(), +})); - afterEach(() => { +describe('sidebarFunctions integration tests', () => { + const dispatch = jest.fn(); + (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + + beforeEach(() => { jest.clearAllMocks(); }); - it('calls handleCreateFileClick if tab is "create"', () => { - const fileName = 'example.md'; + test('handleCreateFileClick with DigitalTwin asset', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); const files: FileState[] = [ - { name: fileName, content: 'content', isNew: true, isModified: false }, + { name: 'testFile', isNew: true, content: 'content', isModified: false }, ]; - handleFileClick( - fileName, + handleCreateFileClick( + 'testFile', null, - mockSetFileName, - mockSetFileContent, - mockSetFileType, files, - 'create', + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, ); - expect(mockSetFileName).toHaveBeenCalledWith(fileName); - expect(mockSetFileContent).toHaveBeenCalledWith('content'); + + expect(updateFileState).toHaveBeenCalled(); + expect(setIsLibraryFile).toHaveBeenCalledWith(false); + expect(setLibraryAssetPath).toHaveBeenCalledWith(''); }); - it('calls handleReconfigureFileClick if tab is "reconfigure"', () => { - const fileName = 'example.json'; - const files: FileState[] = [ - { name: fileName, content: 'content', isNew: false, isModified: true }, + test('handleReconfigureFileClick with modified file', async () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const files = [ + { name: 'testFile', isModified: true, isNew: false, content: 'content' }, ]; - handleFileClick( - fileName, - mockDigitalTwin, - mockSetFileName, - mockSetFileContent, - mockSetFileType, + await handleReconfigureFileClick( + 'testFile', + null, files, - 'reconfigure', + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, ); - expect(mockSetFileName).toHaveBeenCalledWith(fileName); - expect(mockSetFileContent).toHaveBeenCalledWith('content'); - }); -}); -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), -})); + expect(updateFileState).toHaveBeenCalled(); + expect(setIsLibraryFile).toHaveBeenCalledWith(false); + expect(setLibraryAssetPath).toHaveBeenCalledWith(''); + }); -describe('handleFileSubmit', () => { - const mockSetErrorMessage = jest.fn(); - const mockSetIsFileNameDialogOpen = jest.fn(); - const mockSetNewFileName = jest.fn(); - const dispatch = jest.fn(); + test('handleAddFileClick', () => { + const setIsFileNameDialogOpen = jest.fn(); - (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + handleAddFileClick(setIsFileNameDialogOpen); - afterEach(() => { - jest.clearAllMocks(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(true); }); - it('dispatches addOrUpdateFile if new file name does not exist', () => { - const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, - ]; - const newFileName = 'newFile.md'; + test('handleCloseFileNameDialog', () => { + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); + const setErrorMessage = jest.fn(); - handleFileSubmit( - files, - newFileName, - mockSetErrorMessage, - dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, - ); - expect(dispatch).toHaveBeenCalledWith( - addOrUpdateFile({ - name: newFileName, - content: '', - isNew: true, - isModified: false, - type: 'description', - }), + handleCloseFileNameDialog( + setIsFileNameDialogOpen, + setNewFileName, + setErrorMessage, ); - expect(mockSetIsFileNameDialogOpen).toHaveBeenCalledWith(false); - expect(mockSetNewFileName).toHaveBeenCalledWith(''); + + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + expect(setNewFileName).toHaveBeenCalledWith(''); + expect(setErrorMessage).toHaveBeenCalledWith(''); }); - it('sets error message if file name already exists', () => { + test('handleFileSubmit with existing file', () => { const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, + { name: 'testFile', content: 'content', isNew: false, isModified: false }, ]; - const newFileName = 'existingFile.md'; + const setErrorMessage = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); handleFileSubmit( files, - newFileName, - mockSetErrorMessage, + 'testFile', + setErrorMessage, dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, + setIsFileNameDialogOpen, + setNewFileName, ); - expect(mockSetErrorMessage).toHaveBeenCalledWith( + + expect(setErrorMessage).toHaveBeenCalledWith( 'A file with this name already exists.', ); - expect(dispatch).not.toHaveBeenCalled(); }); - it('sets error message if file name is empty', () => { - const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, - ]; - const newFileName = ''; + test('handleFileSubmit with new file', () => { + const files: FileState[] = []; + const setErrorMessage = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); handleFileSubmit( files, - newFileName, - mockSetErrorMessage, + 'newFile', + setErrorMessage, dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, + setIsFileNameDialogOpen, + setNewFileName, ); - expect(mockSetErrorMessage).toHaveBeenCalledWith( - "File name can't be empty.", - ); - expect(dispatch).not.toHaveBeenCalled(); + + expect(setErrorMessage).toHaveBeenCalledWith(''); + expect(dispatch).toHaveBeenCalled(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + expect(setNewFileName).toHaveBeenCalledWith(''); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx index 126eda358..fe3449e00 100644 --- a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx @@ -42,7 +42,7 @@ describe('PipelineUtils', () => { store.dispatch, ); - const state = store.getState().digitalTwin; + const state = store.getState().digitalTwin.digitalTwin; expect(state.mockedDTName.jobLogs).toEqual([ { jobName: 'job1', log: 'log1' }, ]); diff --git a/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx index 03bab4b94..f08814fdb 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx @@ -1,164 +1,173 @@ -import AssetBoard from 'preview/components/asset/AssetBoard'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; import * as React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; +import assetsReducer from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice, { showSnackbar } from 'preview/store/snackbar.slice'; +import fileSlice, { removeAllModifiedFiles } from 'preview/store/file.slice'; +import libraryConfigFilesSlice, { + removeAllModifiedLibraryFiles, +} from 'preview/store/libraryConfigFiles.slice'; import DigitalTwin from 'preview/util/digitalTwin'; 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 { - addOrUpdateFile, - removeAllModifiedFiles, -} from 'preview/store/file.slice'; -import DTAssets from 'preview/util/DTAssets'; -import setupStore from './utils'; - -jest.useFakeTimers(); - -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), -})); -describe('ReconfigureDialog', () => { - let storeConfig: ReturnType; - let dispatchSpy: jest.SpyInstance; +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); - beforeEach(() => { - storeConfig = setupStore(); +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.fullDescription = 'Digital Twin Description'; + +const initialState = { + assets: { items: [] }, + digitalTwin: { + assetName: 'Asset 1', + digitalTwin: mockDigitalTwin, + shouldFetchDigitalTwins: false, + }, + snackbar: {}, + files: [], + libraryConfigFiles: [], + cart: { assets: [] }, +}; + +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, + cart: (state = initialState.cart) => state, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); - dispatchSpy = jest.spyOn(storeConfig, 'dispatch'); +describe('ReconfigureDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; - act(() => { - render( - - - , - ); - }); + beforeEach(() => { + setupTest(); }); afterEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); }); - it('closes the ConfirmationDialog with No', async () => { - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); - await act(async () => { - cancelButton.click(); - }); - - const noButton = await screen.findByRole('button', { name: /No/i }); - await act(async () => { - noButton.click(); - }); + it('renders ReconfigureDialog', async () => { + render( + + + , + ); await waitFor(() => { - expect(screen.queryByText('Are you sure you want to cancel?')).toBeNull(); - expect(screen.queryByText('Editor')).toBeInTheDocument(); + expect(screen.getByText(/Reconfigure/i)).toBeInTheDocument(); }); }); - it('closes the Confirmation dialog with Yes', async () => { - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); - await act(async () => { - cancelButton.click(); - }); + it('opens save confirmation dialog on save button click', async () => { + render( + + + , + ); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); - await act(async () => { - yesButton.click(); - }); + fireEvent.click(screen.getByText('Save')); await waitFor(() => { - expect(screen.queryByText('Editor')).toBeNull(); + expect( + screen.getByText('Are you sure you want to apply the changes?'), + ).toBeInTheDocument(); }); - - expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); }); - it('updates the description when description.md is modified', async () => { - const updateFileContent = jest - .spyOn(DTAssets.prototype, 'updateFileContent') - .mockResolvedValue(); - const modifiedFile = { - name: 'description.md', - content: 'New content', - isNew: false, - isModified: true, - }; - - act(() => { - storeConfig.dispatch(addOrUpdateFile(modifiedFile)); - }); + it('opens cancel confirmation dialog on cancel button click', async () => { + render( + + + , + ); - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); + fireEvent.click(screen.getByText('Cancel')); - const saveButton = await screen.findByRole('button', { name: /Save/i }); - await act(async () => { - saveButton.click(); + await waitFor(() => { + expect( + screen.getByText(/Are you sure you want to cancel?/i), + ).toBeInTheDocument(); }); + }); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); - await act(async () => { - yesButton.click(); - }); + it('dispatches actions on confirm save', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + , + ); + + fireEvent.click(screen.getByText('Save')); + fireEvent.click(screen.getByText('Yes')); await waitFor(() => { - expect(updateFileContent).toHaveBeenCalled(); - const state = storeConfig.getState(); - expect(state.digitalTwin['Asset 1'].description).toBe('New content'); + expect(dispatchSpy).toHaveBeenCalledWith( + showSnackbar({ + message: 'Asset 1 reconfigured successfully', + severity: 'success', + }), + ); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedLibraryFiles()); }); }); - it('calls handleCloseReconfigureDialog when the dialog is closed', () => { - const setShowDialog = jest.fn(); - ReconfigureDialog.handleCloseReconfigureDialog(setShowDialog); - expect(setShowDialog).toHaveBeenCalledWith(false); - }); + it('dispatches actions on confirm cancel', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + , + ); - it('should dispatch error message when updateFileContent throws an error', async () => { - const file = { - name: 'test.md', - content: 'Content', - isNew: false, - isModified: true, - }; - const digitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); - const dispatch = jest.fn(); - - jest - .spyOn(digitalTwin.DTAssets, 'updateFileContent') - .mockRejectedValue(new Error('Mocked error')); - - await act(async () => { - await ReconfigureDialog.handleFileUpdate(file, digitalTwin, dispatch); - }); + fireEvent.click(screen.getByText('Cancel')); + fireEvent.click(screen.getByText('Yes')); - expect(dispatch).toHaveBeenCalledWith( - showSnackbar({ - message: 'Error updating file test.md: Error: Mocked error', - severity: 'error', - }), - ); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedLibraryFiles()); + }); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx index aa330fa7f..059b56024 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx @@ -1,90 +1,65 @@ -import { render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import DigitalTwin from 'preview/util/digitalTwin'; +import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; -import AssetBoard from 'preview/components/asset/AssetBoard'; -import setupStore from './utils'; - -jest.useFakeTimers(); +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import DeleteDialog from 'preview/route/digitaltwins/manage/DeleteDialog'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), })); -describe('DeleteDialog', () => { - let storeDelete: ReturnType; +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.delete = jest.fn().mockResolvedValue('Deleted successfully'); + +const store = configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('DeleteDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; beforeEach(() => { - storeDelete = setupStore(); - - React.act(() => { - render( - - - , - ); - }); + setupTest(); }); 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(); - }); + it('closes DeleteDialog on Cancel button click', async () => { + const setShowDialog = jest.fn(); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); + render( + + + , + ); - React.act(() => { - yesButton.click(); - }); + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); - 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'); - }); + expect(setShowDialog).toHaveBeenCalledWith(false); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx index a43676a61..1456b825b 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx @@ -1,69 +1,120 @@ -import AssetBoard from 'preview/components/asset/AssetBoard'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; import * as React from 'react'; -import setupStore from './utils'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import DetailsDialog from 'preview/route/digitaltwins/manage/DetailsDialog'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import fileSlice from 'preview/store/file.slice'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), })); -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), -})); +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.fullDescription = 'Digital Twin Description'; + +const mockLibraryAsset = new LibraryAsset( + 'Asset 1', + 'path/to/asset', + true, + 'Digital Twins', + mockGitlabInstance, +); +mockLibraryAsset.fullDescription = 'Library Asset Description'; -jest.useFakeTimers(); +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); -describe('DetailsDialog', () => { - let storeDetails: ReturnType; +describe('DetailsDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch(setAssets([mockLibraryAsset])); + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; beforeEach(() => { - storeDetails = setupStore(); - - act(() => { - render( - - - , - ); - }); + setupTest(); }); afterEach(() => { jest.clearAllMocks(); }); - it('renders the AssetCardManage with Details button', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - expect(detailsButton).toBeInTheDocument(); - }); + it('renders DetailsDialog with Digital Twin description', async () => { + render( + + + , + ); - it('opens the DetailsDialog when the Details button is clicked', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - act(() => { - detailsButton.click(); + await waitFor(() => { + expect(screen.getByText('Digital Twin Description')).toBeInTheDocument(); }); + }); + + it('renders DetailsDialog with Library Asset description', async () => { + render( + + + , + ); await waitFor(() => { - const detailsDialog = screen.getByText(/There is no README\.md file/); - expect(detailsDialog).toBeInTheDocument(); + expect(screen.getByText('Library Asset Description')).toBeInTheDocument(); }); }); - it('closes the DetailsDialog when the Close button is clicked', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - act(() => { - detailsButton.click(); - }); + it('closes DetailsDialog on Close button click', async () => { + const setShowDialog = jest.fn(); - const closeButton = await screen.findByRole('button', { name: /Close/i }); + render( + + + , + ); - act(() => { - closeButton.click(); - }); + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); - await waitFor(() => { - expect(screen.queryByText('There is no README.md file')).toBeNull(); - }); + expect(setShowDialog).toHaveBeenCalledWith(false); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/utils.ts b/client/test/preview/integration/route/digitaltwins/manage/utils.ts index 43e72010f..1de6e1edf 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/utils.ts +++ b/client/test/preview/integration/route/digitaltwins/manage/utils.ts @@ -1,5 +1,4 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; -import { Asset } from 'preview/components/asset/Asset'; import fileSlice, { FileState, addOrUpdateFile, @@ -9,11 +8,15 @@ import digitalTwinReducer, { setDigitalTwin, } from 'preview/store/digitalTwin.slice'; import snackbarReducer from 'preview/store/snackbar.slice'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; const setupStore = () => { - const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const preSetItems: LibraryAsset[] = [mockLibraryAsset]; const files: FileState[] = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, ]; diff --git a/client/test/preview/integration/route/library/LibraryPreview.test.tsx b/client/test/preview/integration/route/library/LibraryPreview.test.tsx new file mode 100644 index 000000000..1f5ce5d23 --- /dev/null +++ b/client/test/preview/integration/route/library/LibraryPreview.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import LibraryPreview from 'preview/route/library/LibraryPreview'; +import store from 'store/store'; +import { act, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { useAuth } from 'react-oidc-context'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); + +describe('Library Preview', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('displays content of tabs', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + profile: { + profile: 'testProfileUrl', + }, + }, + }); + + await act(async () => { + render( + + + + + , + ); + }); + + expect(screen.getByText('Selection')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AddToCartButton.test.tsx b/client/test/preview/unit/components/asset/AddToCartButton.test.tsx new file mode 100644 index 000000000..0ba116d61 --- /dev/null +++ b/client/test/preview/unit/components/asset/AddToCartButton.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import AddToCartButton from 'preview/components/asset/AddToCartButton'; +import * as React from 'react'; +import * as cartAccess from 'preview/store/CartAccess'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; + +describe('AddToCartButton', () => { + const addMock = jest.fn(); + const removeMock = jest.fn(); + const clearMock = jest.fn(); + + beforeEach(() => { + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectAssetByPathAndPrivacy('path', true)) { + return mockLibraryAsset; + } + return mockLibraryAsset; + }, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should add asset to cart when not in cart', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [] }, + actions: { + add: addMock, + remove: removeMock, + clear: clearMock, + }, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(addMock).toHaveBeenCalled(); + }); + + it('should remove asset to cart when not in cart', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { + add: addMock, + remove: removeMock, + clear: clearMock, + }, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(removeMock).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AssetBoard.test.tsx b/client/test/preview/unit/components/asset/AssetBoard.test.tsx index 37861fba2..27f1b4046 100644 --- a/client/test/preview/unit/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetBoard.test.tsx @@ -38,13 +38,20 @@ describe('AssetBoard', () => { ); const mockAssets = [ - { name: 'Asset 1', description: 'Test Asset', path: 'path1' }, + { + name: 'Asset 1', + description: 'Test Asset', + path: 'path1', + type: 'Digital Twins', + isPrivate: true, + }, ]; (useSelector as jest.MockedFunction).mockImplementation( (selector) => selector({ assets: { items: mockAssets }, + digitalTwin: { shouldFetchDigitalTwins: false }, }), ); }); @@ -73,17 +80,4 @@ describe('AssetBoard', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); }); - - it('shows error message when error is set', () => { - const realUseState = React.useState; - - const stubInitialState: unknown = ['Error message']; - jest - .spyOn(React, 'useState') - .mockImplementationOnce(() => realUseState(stubInitialState)); - - renderAssetBoard('Manage'); - - expect(screen.getByText('Error message')).toBeInTheDocument(); - }); }); diff --git a/client/test/preview/unit/components/asset/AssetCard.test.tsx b/client/test/preview/unit/components/asset/AssetCard.test.tsx index d0aef58e4..7842fa4d9 100644 --- a/client/test/preview/unit/components/asset/AssetCard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetCard.test.tsx @@ -42,61 +42,67 @@ const asset = { name: 'asset', description: 'Asset description', path: 'path', + type: 'Digital twins', + isPrivate: true, }; -describe('AssetCardManage', () => { +const setupMockStore = (assetDescription: string, twinDescription: string) => { + const state = { + assets: { + items: [ + { + name: 'asset', + path: 'path', + isPrivate: true, + description: assetDescription, + }, + ], + }, + digitalTwin: { + digitalTwin: { + asset: { description: twinDescription }, + }, + }, + }; + (useSelector as jest.MockedFunction).mockImplementation( + (selector) => selector(state), + ); +}; + +const renderComponent = ( + Component: React.JSXElementConstructor, + props: T, +) => { + render( + + + , + ); +}; + +describe('AssetCard', () => { afterEach(() => { jest.clearAllMocks(); }); it('renders AssetCardManage with digital twin description', () => { - (useSelector as jest.MockedFunction).mockImplementation( - (selector) => - selector({ - digitalTwin: { - [asset.name]: { description: 'Digital Twin description' }, - }, - }), - ); - - render( - - {}} /> - , - ); + setupMockStore('Asset description', 'Digital Twin description'); + renderComponent(AssetCardManage, { asset, onDelete: jest.fn() }); expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); - expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByText('Asset 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(); - }); it('renders AssetCardExecute with digital twin description', () => { - (useSelector as jest.MockedFunction).mockImplementation( - (selector) => - selector({ - digitalTwin: { - [asset.name]: { description: 'Digital Twin description' }, - }, - }), - ); - - render( - - - , - ); + setupMockStore('Asset description', 'Digital Twin description'); + renderComponent(AssetCardExecute, { asset }); expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); - expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByText('Asset description')).toBeInTheDocument(); expect(screen.getByTestId('custom-snackbar')).toBeInTheDocument(); expect(screen.getByTestId('log-dialog')).toBeInTheDocument(); }); diff --git a/client/test/preview/unit/components/asset/AssetLibrary.test.tsx b/client/test/preview/unit/components/asset/AssetLibrary.test.tsx new file mode 100644 index 000000000..770c8fca1 --- /dev/null +++ b/client/test/preview/unit/components/asset/AssetLibrary.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { Provider, useSelector } from 'react-redux'; +import AssetLibrary from 'preview/components/asset/AssetLibrary'; +import store, { RootState } from 'store/store'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import { selectAssetsByTypeAndPrivacy } from 'preview/store/assets.slice'; + +jest.mock('preview/store/assets.slice', () => ({ + ...jest.requireActual('preview/store/assets.slice'), + selectAssetsByTypeAndPrivacy: jest.fn(() => []), +})); + +jest.mock('preview/util/init', () => ({ + fetchLibraryAssets: jest.fn(() => Promise.resolve()), +})); + +jest.mock('preview/components/asset/Filter', () => ({ + __esModule: true, + default: () =>
    Filter
    , +})); + +jest.mock('preview/components/asset/AssetCard', () => ({ + __esModule: true, + AssetCardLibrary: () =>
    Asset Card Library
    , +})); + +describe('AssetLibrary', () => { + beforeEach(() => { + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectAssetsByTypeAndPrivacy('path', false)) { + return [mockLibraryAsset]; + } + return []; + }, + ); + }); + + const renderAssetLibrary = () => + act(async () => { + render( + + + , + ); + }); + + it('renders assets when fetched', async () => { + await renderAssetLibrary(); + + await waitFor(() => + expect(screen.getByText('Asset Card Library')).toBeInTheDocument(), + ); + }); +}); diff --git a/client/test/preview/unit/components/asset/DetailsButton.test.tsx b/client/test/preview/unit/components/asset/DetailsButton.test.tsx index 729162c0d..899de8921 100644 --- a/client/test/preview/unit/components/asset/DetailsButton.test.tsx +++ b/client/test/preview/unit/components/asset/DetailsButton.test.tsx @@ -14,11 +14,16 @@ jest.mock('react-redux', () => ({ describe('DetailsButton', () => { const renderDetailsButton = ( assetName: string, + assetPrivacy: boolean, setShowDetails: Dispatch>, ) => render( - + , ); @@ -27,7 +32,7 @@ describe('DetailsButton', () => { }); it('renders the Details button', () => { - renderDetailsButton('AssetName', jest.fn()); + renderDetailsButton('AssetName', true, jest.fn()); expect( screen.getByRole('button', { name: /Details/i }), ).toBeInTheDocument(); @@ -42,7 +47,7 @@ describe('DetailsButton', () => { getFullDescription: jest.fn().mockResolvedValue('Mocked description'), }); - renderDetailsButton('AssetName', mockSetShowDetails); + renderDetailsButton('AssetName', true, mockSetShowDetails); const detailsButton = screen.getByRole('button', { name: /Details/i }); fireEvent.click(detailsButton); diff --git a/client/test/preview/unit/components/cart/CartList.test.tsx b/client/test/preview/unit/components/cart/CartList.test.tsx new file mode 100644 index 000000000..45289c8b6 --- /dev/null +++ b/client/test/preview/unit/components/cart/CartList.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import CartList from 'preview/components/cart/CartList'; +import * as React from 'react'; +import * as cartAccess from 'preview/store/CartAccess'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +describe('CartList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render a list of assets', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { add: jest.fn(), remove: jest.fn(), clear: jest.fn() }, + }); + + render(); + + expect(screen.getByText('path')).toBeInTheDocument(); + }); + + it('should render a list of common assets', () => { + mockLibraryAsset.isPrivate = false; + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { add: jest.fn(), remove: jest.fn(), clear: jest.fn() }, + }); + + render(); + + expect(screen.getByText('common/path')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx index 2633b1131..635b58ed8 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx @@ -47,6 +47,7 @@ describe('CreateDialogs', () => { setNewDigitalTwinName: mockSetNewDigitalTwinName, errorMessage: '', setErrorMessage: mockSetErrorMessage, + isPrivate: true, }; beforeEach(() => { diff --git a/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx index 724282fb9..423d27f4b 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx @@ -12,11 +12,6 @@ jest.mock('preview/route/digitaltwins/create/CreateDialogs', () => ({ default: () =>
    , })); -jest.mock('preview/route/digitaltwins/create/FileActionButtons', () => ({ - _esModule: true, - default: () =>
    , -})); - jest.mock('preview/route/digitaltwins/Snackbar', () => ({ _esModule: true, default: () =>
    , @@ -42,7 +37,6 @@ describe('CreatePage', () => { expect(screen.getByText('Save')).toBeInTheDocument(); expect(screen.getByTestId('editor')).toBeInTheDocument(); expect(screen.getByTestId('create-dialogs')).toBeInTheDocument(); - expect(screen.getByTestId('file-action-buttons')).toBeInTheDocument(); expect(screen.getByTestId('snackbar')).toBeInTheDocument(); }); diff --git a/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx index 515fcb28d..88169da3f 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx @@ -11,12 +11,13 @@ describe('FileActionButtons', () => { fileName="file" setOpenDeleteFileDialog={setOpenDeleteFileDialog} setOpenChangeFileNameDialog={jest.fn()} + isLibraryFile={false} />, ); }); it('should render FileActionButtons', () => { expect(screen.getByText('Delete File')).toBeInTheDocument(); - expect(screen.getByText('Change File Name')).toBeInTheDocument(); + expect(screen.getByText('Rename File')).toBeInTheDocument(); }); it('handles click on delete file button', () => { @@ -25,7 +26,7 @@ describe('FileActionButtons', () => { }); it('handles click on change file name button', () => { - screen.getByText('Change File Name').click(); + screen.getByText('Rename File').click(); expect(setOpenDeleteFileDialog).not.toBeCalled(); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx index 23fcd7ecd..04acf4ff0 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx @@ -29,6 +29,12 @@ describe('Editor', () => { setFileContent={jest.fn()} fileType={'fileType'} setFileType={jest.fn()} + filePrivacy={'private'} + setFilePrivacy={jest.fn()} + isLibraryFile={false} + setIsLibraryFile={jest.fn()} + libraryAssetPath={''} + setLibraryAssetPath={jest.fn()} />, ); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx index d4cbbdef8..c8b350bf3 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx @@ -1,7 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import EditorTab from 'preview/route/digitaltwins/editor/EditorTab'; +import EditorTab, { + handleEditorChange, +} from 'preview/route/digitaltwins/editor/EditorTab'; import { addOrUpdateFile } from 'preview/store/file.slice'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; jest.mock('preview/store/file.slice', () => ({ addOrUpdateFile: jest.fn(), @@ -25,10 +28,13 @@ describe('EditorTab', () => { waitFor(async () => { render( , ); @@ -39,34 +45,103 @@ describe('EditorTab', () => { }); }); - it('calls handleEditorChange via onChange correctly', async () => { - waitFor(async () => { - render( - , - ); + it('calls handleEditorChange via onChange correctly - create tab', async () => { + await handleEditorChange( + 'create', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + false, + '', + mockDispatch, + ); - const newValue = 'New content'; + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateFile({ + name: 'fileName', + content: 'new content', + isNew: true, + isModified: true, + }), + ); + }); - fireEvent.change(screen.getByRole('textbox'), { - target: { value: newValue }, - }); + it('calls handleEditorChange via onChange correctly - create tab and libraryFile', async () => { + await handleEditorChange( + 'create', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + true, + 'path', + mockDispatch, + ); - await waitFor(() => { - expect(mockSetFileContent).toHaveBeenCalledWith(newValue); - expect(mockDispatch).toHaveBeenCalledWith( - addOrUpdateFile({ - name: 'fileName', - content: newValue, - isNew: false, - isModified: true, - }), - ); - }); - }); + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: 'path', + fileName: 'fileName', + fileContent: 'new content', + isNew: true, + isModified: true, + isPrivate: true, + }), + ); + }); + + it('calls handleEditorChange via onChange correctly - reconfigure tab', async () => { + await handleEditorChange( + 'reconfigure', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + false, + '', + mockDispatch, + ); + + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateFile({ + name: 'fileName', + content: 'new content', + isNew: true, + isModified: true, + }), + ); + }); + + it('calls handleEditorChange via onChange correctly - reconfigure tab and libraryFile', async () => { + await handleEditorChange( + 'reconfigure', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + true, + 'path', + mockDispatch, + ); + + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: 'path', + fileName: 'fileName', + fileContent: 'new content', + isNew: false, + isModified: true, + isPrivate: true, + }), + ); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx index b440ca773..8bfe09cd1 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx @@ -1,18 +1,15 @@ -import { - render, - waitFor, - screen, - act, - fireEvent, -} from '@testing-library/react'; +import { render, waitFor, screen, act } from '@testing-library/react'; import Sidebar from 'preview/route/digitaltwins/editor/Sidebar'; -import { handleReconfigureFileClick } from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; -import { FileState } from 'preview/store/file.slice'; +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; 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'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import * as ReactRedux from 'react-redux'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -23,6 +20,11 @@ describe('Sidebar', () => { const setFileName = jest.fn(); const setFileContent = jest.fn(); const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const fileName = 'testFile.md'; + const isLibraryFile = false; const renderSidebar = async (tab: string, name?: string) => { await act(async () => { @@ -33,25 +35,31 @@ describe('Sidebar', () => { setFileName={setFileName} setFileContent={setFileContent} setFileType={setFileType} + setFilePrivacy={setFilePrivacy} + setIsLibraryFile={setIsLibraryFile} + setLibraryAssetPath={setLibraryAssetPath} tab={tab} + fileName={fileName} + isLibraryFile={isLibraryFile} /> , ); }); }; - beforeEach(async () => { + beforeEach(() => { (useSelector as jest.MockedFunction).mockImplementation( (selector: (state: RootState) => unknown) => { - if (selector === selectDigitalTwinByName('mockedDTName')) { - return mockDigitalTwin; - } if (selector.toString().includes('state.files')) { return []; } + if (selector.toString().includes('state.cart.assets')) { + return [mockLibraryAsset]; + } return mockDigitalTwin; }, ); + jest.clearAllMocks(); }); afterEach(() => { @@ -68,85 +76,52 @@ describe('Sidebar', () => { }); }); - it('should update file state if the file is modified', async () => { - await renderSidebar('reconfigure', 'mockedDTName'); + it('should call handleAddFileClick when Add new file button is clicked', async () => { + const handleAddFileClickSpy = jest.spyOn( + SidebarFunctions, + 'handleAddFileClick', + ); - const modifiedFiles: FileState[] = [ - { - name: 'testFile.md', - content: 'modified content', - isNew: false, - isModified: true, - }, - ]; + await renderSidebar('create', 'mockedDTName'); - await act(async () => { - handleReconfigureFileClick( - 'testFile.md', - mockDigitalTwin, - modifiedFiles, - setFileName, - setFileContent, - setFileType, - ); + await waitFor(() => { + const addFileButton = screen.getByText('Add new file'); + addFileButton.click(); }); - expect(setFileName).toHaveBeenCalledWith('testFile.md'); - expect(setFileContent).toHaveBeenCalledWith('modified content'); - expect(setFileType).toHaveBeenCalledWith('md'); - expect(mockDigitalTwin.DTAssets.getFileContent).not.toHaveBeenCalled(); + expect(handleAddFileClickSpy).toHaveBeenCalled(); }); - it('should fetch and update file state if the file is not modified', async () => { - await renderSidebar('reconfigure', 'mockedDTName'); - - const modifiedFiles: FileState[] = []; - mockDigitalTwin.DTAssets.getFileContent = jest - .fn() - .mockResolvedValue('fetched content'); + it('should render file sections', async () => { + await renderSidebar('reconfigure', 'differentDTName'); - await act(async () => { - await handleReconfigureFileClick( - 'testFile.md', - mockDigitalTwin, - modifiedFiles, - setFileName, - setFileContent, - setFileType, - ); + await waitFor(() => { + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Configuration')).toBeInTheDocument(); + expect(screen.getByText('assetPath configuration')).toBeInTheDocument(); }); - - expect(mockDigitalTwin.DTAssets.getFileContent).toHaveBeenCalledWith( - 'testFile.md', - ); - expect(setFileName).toHaveBeenCalledWith('testFile.md'); - expect(setFileContent).toHaveBeenCalledWith('fetched content'); - expect(setFileType).toHaveBeenCalledWith('md'); }); - it('opens the file name dialog when Add new file button is clicked', async () => { + it('handles assets in create mode', async () => { + const addOrUpdateLibraryFileSpy = jest.spyOn(ReactRedux, 'useDispatch'); + await renderSidebar('create'); await waitFor(() => { - fireEvent.click(screen.getByText('Add new file')); + expect(addOrUpdateLibraryFileSpy).toHaveBeenCalled(); + mockLibraryAsset.configFiles.forEach((file) => { + expect(addOrUpdateLibraryFileSpy).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: mockLibraryAsset.path, + fileName: file, + fileContent: '', + isNew: true, + isModified: false, + isPrivate: mockLibraryAsset.isPrivate, + }), + ); + }); }); - - expect(screen.getByText('Enter the file name')).toBeInTheDocument(); - }); - - it('renders Sidebar with null digitalTwin when tab is create', async () => { - (useSelector as unknown as jest.Mock).mockImplementationOnce( - (selector: (state: RootState) => unknown) => { - if (selector === selectDigitalTwinByName('')) { - return null; - } - if (selector.toString().includes('state.files')) { - return []; - } - return null; - }, - ); - - await renderSidebar('create', ''); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx deleted file mode 100644 index b3e7fd7f1..000000000 --- a/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import { FileState } from 'preview/store/file.slice'; -import { mockDigitalTwin as mockDigitalTwinInstance } from 'test/preview/__mocks__/global_mocks'; // Rinominato -import { SimpleTreeView } from '@mui/x-tree-view'; -import * as React from 'react'; -import DigitalTwin from 'preview/util/digitalTwin'; - -describe('SidebarFunctions', () => { - const setFileName = jest.fn(); - const setFileContent = jest.fn(); - const setFileType = jest.fn(); - const setErrorMessage = jest.fn(); - const dispatch = jest.fn(); - const setIsFileNameDialogOpen = jest.fn(); - const setNewFileName = jest.fn(); - - afterEach(() => { - jest.clearAllMocks(); - }); - - const files: FileState[] = []; // spostato qui per evitare conflitti di scope - - it('should return the correct file type from the extension', () => { - expect(SidebarFunctions.getFileTypeFromExtension('file.md')).toBe( - 'description', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.json')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.yaml')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.yml')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file')).toBe('lifecycle'); - }); - - it('should handle file click correctly in create tab', () => { - const tab = 'create'; - const handleCreateFileClick = jest - .spyOn(SidebarFunctions, 'handleCreateFileClick') - .mockImplementation(jest.fn()); - - SidebarFunctions.handleFileClick( - 'file', - null, - setFileName, - setFileContent, - setFileType, - files, - tab, - ); - - expect(handleCreateFileClick).toHaveBeenCalled(); - }); - - it('should handle file click correctly in reconfigure tab', () => { - const tab = 'reconfigure'; - const handleReconfigureFileClick = jest - .spyOn(SidebarFunctions, 'handleReconfigureFileClick') - .mockImplementation(jest.fn()); - - SidebarFunctions.handleFileClick( - 'file', - null, - setFileName, - setFileContent, - setFileType, - files, - tab, - ); - - expect(handleReconfigureFileClick).toHaveBeenCalled(); - }); - - it('should render file tree items correctly and handle file click', () => { - const handleFileClick = jest - .spyOn(SidebarFunctions, 'handleFileClick') - .mockImplementation(jest.fn()); - - render( - - {SidebarFunctions.renderFileTreeItems( - 'label', - ['file'], - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - )} - , - ); - - expect(screen.getByText('label')).toBeInTheDocument(); - fireEvent.click(screen.getByText('label')); - expect(screen.getByText('file')).toBeInTheDocument(); - fireEvent.click(screen.getByText('file')); - - expect(handleFileClick).toHaveBeenCalled(); - }); - - it('should get filtered files name correctly', () => { - const testFiles: FileState[] = [ - { name: 'file1.md', content: 'content', isNew: false, isModified: false }, - { name: 'file2', content: 'content', isNew: true, isModified: false }, - { name: 'file3', content: 'content', isNew: true, isModified: false }, - ]; - expect( - SidebarFunctions.getFilteredFileNames('lifecycle', testFiles), - ).toEqual(['file2', 'file3']); - }); - - it('should render file section correctly and handle file click', () => { - const handleFileClick = jest - .spyOn(SidebarFunctions, 'handleFileClick') - .mockImplementation(jest.fn()); - - render( - - {SidebarFunctions.renderFileSection( - 'label', - 'type', - ['file'], - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - )} - , - ); - - expect(screen.getByText('label')).toBeInTheDocument(); - fireEvent.click(screen.getByText('label')); - expect(screen.getByText('file')).toBeInTheDocument(); - - fireEvent.click(screen.getByText('file')); - expect(handleFileClick).toHaveBeenCalledWith( - 'file', - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - ); - }); - - it('should not call updateFileState if no new file is found', () => { - const testFiles: FileState[] = [ - { name: 'file1.md', content: 'content', isNew: false, isModified: false }, - ]; - const updateFileStateSpy = jest.spyOn(SidebarFunctions, 'updateFileState'); - - SidebarFunctions.handleCreateFileClick( - 'nonExistentFile', - testFiles, - setFileName, - setFileContent, - setFileType, - ); - - expect(updateFileStateSpy).not.toHaveBeenCalled(); - }); - - it('should set file content error message when fetching fails', async () => { - const mockDigitalTwin: DigitalTwin = { - fileHandler: { - getFileContent: jest.fn().mockRejectedValue(new Error('Fetch error')), - }, - } as unknown as DigitalTwin; - - const fileName = 'testFile.md'; - - await SidebarFunctions.fetchAndSetFileContent( - fileName, - mockDigitalTwin, - setFileName, - setFileContent, - setFileType, - ); - - expect(setFileContent).toHaveBeenCalledWith( - `Error fetching ${fileName} content`, - ); - }); - - it('should not handle file submit if name already exists', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - 'file1', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith( - 'A file with this name already exists.', - ); - }); - - it('should not handle file submit if name is empty', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - '', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith("File name can't be empty."); - }); - - it('should handle file submit correctly', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - 'file2', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith(''); - expect(dispatch).toHaveBeenCalled(); - }); -}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts new file mode 100644 index 000000000..9ac06f95e --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts @@ -0,0 +1,125 @@ +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import * as SidebarFetchers from 'preview/route/digitaltwins/editor/sidebarFetchers'; +import * as FileUtils from 'preview/util/fileUtils'; + +describe('sidebarFetchers', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const dispatch = jest.fn(); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should fetch and set file content if library is true', async () => { + const getLibraryFileContentSpy = jest + .spyOn(mockDigitalTwin!.DTAssets, 'getLibraryFileContent') + .mockResolvedValue('fileContent'); + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + true, + 'assetPath', + ); + + expect(getLibraryFileContentSpy).toHaveBeenCalledTimes(1); + expect(updateFileStateSpy).toHaveBeenCalledTimes(1); + }); + + it('should fetch and set file content if not library', async () => { + const getFileContentSpy = jest + .spyOn(mockDigitalTwin!.DTAssets, 'getFileContent') + .mockResolvedValue('fileContent'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + + expect(getFileContentSpy).toHaveBeenCalledTimes(1); + }); + + it('should set error message if error occurs while fetching file content', async () => { + jest + .spyOn(mockDigitalTwin!.DTAssets, 'getFileContent') + .mockRejectedValue('error'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching file1.md content', + ); + }); + + it('should fetch and set file library content', async () => { + const getFileContentSpy = jest + .spyOn(mockLibraryAsset.libraryManager, 'getFileContent') + .mockResolvedValue('fileContent'); + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFetchers.fetchAndSetFileLibraryContent( + 'file1.md', + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + false, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + ); + + expect(getFileContentSpy).toHaveBeenCalledTimes(1); + expect(updateFileStateSpy).toHaveBeenCalledTimes(1); + expect(setIsLibraryFile).toHaveBeenCalledWith(true); + expect(setLibraryAssetPath).toHaveBeenCalledWith(mockLibraryAsset.path); + }); + + it('should set error message if error occurs while fetching file library content', async () => { + jest + .spyOn(mockLibraryAsset.libraryManager, 'getFileContent') + .mockRejectedValue('error'); + + await SidebarFetchers.fetchAndSetFileLibraryContent( + 'file1.md', + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + false, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching file1.md content', + ); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts new file mode 100644 index 000000000..15b17a692 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts @@ -0,0 +1,296 @@ +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import { FileState } from 'preview/store/file.slice'; +import * as FileUtils from 'preview/util/fileUtils'; +import * as SidebarFetchers from 'preview/route/digitaltwins/editor/sidebarFetchers'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/fileUtils'); +jest.mock('preview/route/digitaltwins/editor/sidebarFetchers'); + +describe('SidebarFunctions', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); + const setErrorMessage = jest.fn(); + const dispatch = jest.fn(); + + const files: FileState[] = []; + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should handle file click correctly in create tab', () => { + const tab = 'create'; + const handleCreateFileClick = jest + .spyOn(SidebarFunctions, 'handleCreateFileClick') + .mockImplementation(jest.fn()); + + SidebarFunctions.handleFileClick( + 'file', + null, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(handleCreateFileClick).toHaveBeenCalled(); + }); + + it('should handle file click correctly in reconfigure tab', () => { + const tab = 'reconfigure'; + const handleReconfigureFileClick = jest + .spyOn(SidebarFunctions, 'handleReconfigureFileClick') + .mockImplementation(jest.fn()); + + SidebarFunctions.handleFileClick( + 'file', + null, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(handleReconfigureFileClick).toHaveBeenCalled(); + }); + + it('should not call updateFileState if no new file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: false }, + ]; + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFunctions.handleCreateFileClick( + 'nonExistentFile', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).not.toHaveBeenCalled(); + }); + + it('should call updateFileState if new file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call updateFileState if modified library file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const testLibraryConfigFiles = [ + { + assetPath: 'path', + fileName: 'file1.md', + fileContent: 'content', + isNew: false, + isModified: true, + isPrivate: true, + }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + mockLibraryAsset, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + undefined, + testLibraryConfigFiles, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call fetchAndSetFileLibraryContent if new library file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const testLibraryConfigFiles = [ + { + assetPath: 'path', + fileName: 'file1.md', + fileContent: 'content', + isNew: true, + isModified: false, + isPrivate: true, + }, + ]; + + const fetchAndSetFileLibraryContentSpy = jest + .spyOn(SidebarFetchers, 'fetchAndSetFileLibraryContent') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + mockLibraryAsset, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + undefined, + testLibraryConfigFiles, + ); + + expect(fetchAndSetFileLibraryContentSpy).toHaveBeenCalled(); + }); + + it('should call updateFileState if new file is found - reconfigure tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: true }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleReconfigureFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call fetchAndSetFileContent if new file is found - reconfigure tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: false }, + ]; + + const fetchAndSetFileContentSpy = jest + .spyOn(SidebarFetchers, 'fetchAndSetFileContent') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleReconfigureFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(fetchAndSetFileContentSpy).toHaveBeenCalled(); + }); + + it('should handle add file click correctly', () => { + SidebarFunctions.handleAddFileClick(setIsFileNameDialogOpen); + + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(true); + }); + + it('should handle file submit correctly', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + 'file2', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(dispatch).toHaveBeenCalled(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + }); + + it('should set error message when file name already exists', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + 'file1', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(setErrorMessage).toHaveBeenCalledWith( + 'A file with this name already exists.', + ); + }); + + it('should set error message when file name is empty', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + '', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(setErrorMessage).toHaveBeenCalledWith("File name can't be empty."); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx new file mode 100644 index 000000000..c0aeb0834 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx @@ -0,0 +1,169 @@ +import * as SidebarRendering from 'preview/route/digitaltwins/editor/sidebarRendering'; +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { SimpleTreeView } from '@mui/x-tree-view'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { FileState } from 'preview/store/file.slice'; + +describe('SidebarRendering', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setIsLibraryAssetPath = jest.fn(); + const dispatch = jest.fn(); + + const files: FileState[] = [ + { + name: 'file', + content: 'content', + type: 'type', + isModified: false, + isNew: true, + }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should render file tree items correctly and handle file click - DigitalTwin', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + render( + + {SidebarRendering.renderFileTreeItems( + 'label', + ['file'], + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + fireEvent.click(screen.getByText('file')); + + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file tree items correctly and handle file click - LibraryAsset', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + mockLibraryAsset.isPrivate = false; + + render( + + {SidebarRendering.renderFileTreeItems( + 'label', + ['file'], + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + fireEvent.click(screen.getByText('file')); + + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file section correctly and handle file click - LibraryAsset', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + mockLibraryAsset.isPrivate = false; + + render( + + {SidebarRendering.renderFileSection( + 'label', + 'Digital Twins', + ['file'], + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('file')); + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file section correctly and handle file click - DigitalTwin', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + render( + + {SidebarRendering.renderFileSection( + 'label', + 'Digital Twins', + ['file'], + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('file')); + expect(handleFileClick).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx index e89f660dc..236086e59 100644 --- a/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx @@ -14,6 +14,7 @@ import { showSnackbar } from 'preview/store/snackbar.slice'; import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; import { selectModifiedFiles } from 'preview/store/file.slice'; +import { selectModifiedLibraryFiles } from 'preview/store/libraryConfigFiles.slice'; jest.mock('preview/store/file.slice', () => ({ ...jest.requireActual('preview/store/file.slice'), @@ -76,6 +77,16 @@ describe('ReconfigureDialog', () => { }, ].filter((file) => !file.isNew); } + if (selector === selectModifiedLibraryFiles) { + return [ + { + name: 'libraryFile.md', + content: 'Updated library file', + isNew: false, + isModified: true, + }, + ]; + } return mockDigitalTwin; }, ); @@ -214,7 +225,7 @@ describe('ReconfigureDialog', () => { }); await waitFor(() => { - expect(handleFileUpdateSpy).toHaveBeenCalledTimes(2); + expect(handleFileUpdateSpy).toHaveBeenCalledTimes(3); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx index 0cac83487..ef53dd17d 100644 --- a/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx @@ -20,6 +20,7 @@ describe('DetailsDialog', () => { showDialog={true} setShowDialog={setShowDialog} name="name" + isPrivate={true} />, ); @@ -32,6 +33,7 @@ describe('DetailsDialog', () => { showDialog={true} setShowDialog={setShowDialog} name="name" + isPrivate={true} />, ); diff --git a/client/test/preview/unit/store/CartAccess.test.ts b/client/test/preview/unit/store/CartAccess.test.ts new file mode 100644 index 000000000..ebee7d337 --- /dev/null +++ b/client/test/preview/unit/store/CartAccess.test.ts @@ -0,0 +1,55 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDispatch, useSelector } from 'react-redux'; +import useCart from 'preview/store/CartAccess'; +import * as cart from 'preview/store/cart.slice'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('preview/store/cart.slice', () => ({ + addToCart: jest.fn(), + removeFromCart: jest.fn(), + clearCart: jest.fn(), +})); + +describe('useCart', () => { + const dispatch = jest.fn(); + const mockState = { items: [] }; + + beforeEach(() => { + (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + (useSelector as unknown as jest.Mock).mockImplementation((selector) => + selector({ cart: mockState }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return the cart state', () => { + const { result } = renderHook(() => useCart()); + expect(result.current.state).toEqual(mockState); + }); + + it('should dispatch addToCart action', () => { + const { result } = renderHook(() => useCart()); + const asset = mockLibraryAsset; + act(() => { + result.current.actions.add(asset); + }); + expect(dispatch).toHaveBeenCalledWith(cart.addToCart(asset)); + }); + + it('should dispatch removeFromCart action', () => { + const { result } = renderHook(() => useCart()); + const asset = mockLibraryAsset; + act(() => { + result.current.actions.remove(asset); + }); + expect(dispatch).toHaveBeenCalledWith(cart.removeFromCart(asset)); + }); +}); diff --git a/client/test/preview/unit/Store.test.ts b/client/test/preview/unit/store/Store.test.ts similarity index 59% rename from client/test/preview/unit/Store.test.ts rename to client/test/preview/unit/store/Store.test.ts index 4c7ecdec0..47c0c4bac 100644 --- a/client/test/preview/unit/Store.test.ts +++ b/client/test/preview/unit/store/Store.test.ts @@ -6,15 +6,12 @@ import assetsSlice, { } from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, - setJobLogs, setPipelineCompleted, setPipelineLoading, updateDescription, } from 'preview/store/digitalTwin.slice'; import DigitalTwin from 'preview/util/digitalTwin'; import GitlabInstance from 'preview/util/gitlab'; -import { JobLog } from 'preview/components/asset/StartStopButton'; -import { Asset } from 'preview/components/asset/Asset'; import snackbarSlice, { hideSnackbar, showSnackbar, @@ -28,11 +25,23 @@ import fileSlice, { removeAllModifiedFiles, renameFile, } from 'preview/store/file.slice'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import cartSlice, { + addToCart, + clearCart, + removeFromCart, +} from 'preview/store/cart.slice'; +import libraryFilesSlice, { + LibraryConfigFile, + addOrUpdateLibraryFile, + removeAllModifiedLibraryFiles, +} from 'preview/store/libraryConfigFiles.slice'; describe('reducers', () => { let initialState: { assets: { - items: Asset[]; + items: LibraryAsset[]; }; digitalTwin: { [key: string]: DigitalTwin; @@ -48,6 +57,15 @@ describe('reducers', () => { isNew: boolean; isModified: boolean; }[]; + cart: { + assets: LibraryAsset[]; + }; + libraryConfigFiles: { + name: string; + content: string; + isNew: boolean; + isModified: boolean; + }[]; }; beforeEach(() => { @@ -60,15 +78,13 @@ describe('reducers', () => { severity: 'info', }, files: [], + cart: { assets: [] }, + libraryConfigFiles: [], }; }); describe('assets reducer', () => { - const asset1 = { - name: 'asset1', - description: 'description', - path: 'path', - }; + const asset1 = mockLibraryAsset; it('should handle setAssets', () => { const newState = assetsSlice(initialState.assets, setAssets([asset1])); @@ -90,57 +106,87 @@ describe('reducers', () => { new GitlabInstance('user1', 'authority', 'token1'), ); - it('digitalTwinReducer should return the initial digitalTwin state when an unknown action type is passed with an undefined state', () => { + const initialState = { + digitalTwin: {}, + shouldFetchDigitalTwins: true, + }; + + it('should return the initial state when an unknown action is passed with an undefined state', () => { expect(digitalTwinReducer(undefined, { type: 'unknown' })).toEqual( - initialState.digitalTwin, + initialState, ); }); it('should handle setDigitalTwin', () => { const newState = digitalTwinReducer( - initialState.digitalTwin, + initialState, setDigitalTwin({ assetName: 'asset1', digitalTwin }), ); - expect(newState.asset1).toEqual(digitalTwin); + expect(newState.digitalTwin.asset1).toEqual(digitalTwin); }); - it('should handle setJobLogs', () => { - const jobLogs: JobLog[] = [{ jobName: 'job1', log: 'log' }]; - digitalTwin.jobLogs = jobLogs; - initialState.digitalTwin.asset1 = digitalTwin; - const newState = digitalTwinReducer( - initialState.digitalTwin, - setJobLogs({ assetName: 'asset1', jobLogs }), + it('should handle setPipelineCompleted', () => { + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), ); - expect(newState.asset1.jobLogs).toEqual(jobLogs); - }); + updatedDigitalTwin.pipelineCompleted = false; + + const updatedState = { + digitalTwin: { + asset1: updatedDigitalTwin, + }, + shouldFetchDigitalTwins: true, + }; - it('should handle setPipelineCompleted', () => { - initialState.digitalTwin.asset1 = digitalTwin; const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, setPipelineCompleted({ assetName: 'asset1', pipelineCompleted: true }), ); - expect(newState.asset1.pipelineCompleted).toBe(true); + + expect(newState.digitalTwin.asset1.pipelineCompleted).toBe(true); }); it('should handle setPipelineLoading', () => { - initialState.digitalTwin.asset1 = digitalTwin; + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + updatedDigitalTwin.pipelineLoading = false; + + const updatedState = { + ...initialState, + digitalTwin: { asset1: updatedDigitalTwin }, + }; + const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, setPipelineLoading({ assetName: 'asset1', pipelineLoading: true }), ); - expect(newState.asset1.pipelineLoading).toBe(true); + + expect(newState.digitalTwin.asset1.pipelineLoading).toBe(true); }); it('should handle updateDescription', () => { - initialState.digitalTwin.asset1 = digitalTwin; + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + updatedDigitalTwin.description = ''; + + const updatedState = { + ...initialState, + digitalTwin: { asset1: updatedDigitalTwin }, + }; + const description = 'new description'; + const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, updateDescription({ assetName: 'asset1', description }), ); - expect(newState.asset1.description).toBe(description); + + expect(newState.digitalTwin.asset1.description).toBe(description); }); }); @@ -298,4 +344,104 @@ describe('reducers', () => { expect(newState).toEqual([]); }); }); + + describe('cart reducer', () => { + const asset1 = mockLibraryAsset; + const asset2 = { ...mockLibraryAsset, path: 'path2' }; + + it('should handle addToCart', () => { + const newState = cartSlice(initialState.cart, addToCart(asset1)); + expect(newState.assets).toEqual([asset1]); + }); + + it('should not add duplicate assets to cart', () => { + initialState.cart.assets = [asset1]; + const newState = cartSlice(initialState.cart, addToCart(asset1)); + expect(newState.assets).toEqual([asset1]); + }); + + it('should handle removeFromCart', () => { + initialState.cart.assets = [asset1, asset2]; + const newState = cartSlice(initialState.cart, removeFromCart(asset1)); + expect(newState.assets).toEqual([asset2]); + }); + + it('should handle clearCart', () => { + initialState.cart.assets = [asset1, asset2]; + const newState = cartSlice(initialState.cart, clearCart()); + expect(newState.assets).toEqual([]); + }); + }); + + describe('libraryFilesSlice', () => { + const initialState: LibraryConfigFile[] = []; + + it('should handle initial state', () => { + expect(libraryFilesSlice(undefined, { type: 'unknown' })).toEqual( + initialState, + ); + }); + + it('should handle addOrUpdateLibraryFile', () => { + const newFile: LibraryConfigFile = { + assetPath: 'path1', + fileName: 'file1', + fileContent: 'content1', + isNew: true, + isModified: false, + isPrivate: false, + }; + + const updatedFile: LibraryConfigFile = { + ...newFile, + fileContent: 'updated content', + isModified: true, + }; + + let state = libraryFilesSlice( + initialState, + addOrUpdateLibraryFile(newFile), + ); + expect(state).toEqual([newFile]); + + state = libraryFilesSlice(state, addOrUpdateLibraryFile(updatedFile)); + expect(state).toEqual([updatedFile]); + }); + + it('should handle removeAllModifiedLibraryFiles', () => { + const stateWithFiles: LibraryConfigFile[] = [ + { + assetPath: 'path1', + fileName: 'file1', + fileContent: 'content1', + isNew: false, + isModified: true, + isPrivate: false, + }, + { + assetPath: 'path2', + fileName: 'file2', + fileContent: 'content2', + isNew: true, + isModified: false, + isPrivate: false, + }, + ]; + + const state = libraryFilesSlice( + stateWithFiles, + removeAllModifiedLibraryFiles(), + ); + expect(state).toEqual([ + { + assetPath: 'path2', + fileName: 'file2', + fileContent: 'content2', + isNew: true, + isModified: false, + isPrivate: false, + }, + ]); + }); + }); }); diff --git a/client/test/preview/unit/util/DTAssets.test.ts b/client/test/preview/unit/util/DTAssets.test.ts index 558b8bded..169410335 100644 --- a/client/test/preview/unit/util/DTAssets.test.ts +++ b/client/test/preview/unit/util/DTAssets.test.ts @@ -41,7 +41,7 @@ describe('DTAssets', () => { content: 'content', isNew: true, isModified: false, - type: 'description', + type: 'digital twin', }, { name: 'file2', diff --git a/client/test/preview/unit/util/digitalTwin.test.ts b/client/test/preview/unit/util/digitalTwin.test.ts index 2009931a5..ac0bb0997 100644 --- a/client/test/preview/unit/util/digitalTwin.test.ts +++ b/client/test/preview/unit/util/digitalTwin.test.ts @@ -268,7 +268,7 @@ describe('DigitalTwin', () => { }); it('should create digital twin with files', async () => { - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'test-DTName digital twin files initialized successfully.', @@ -280,7 +280,7 @@ describe('DigitalTwin', () => { new Error('Create failed'), ); - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'Error initializing test-DTName digital twin files: Error: Create failed', @@ -290,7 +290,7 @@ describe('DigitalTwin', () => { it('should return error message when projectId is missing during creation', async () => { dt.gitlabInstance.projectId = null; - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'Error creating test-DTName digital twin: no project id', diff --git a/client/test/preview/unit/util/fileUtils.test.ts b/client/test/preview/unit/util/fileUtils.test.ts index 8b2035950..855c82478 100644 --- a/client/test/preview/unit/util/fileUtils.test.ts +++ b/client/test/preview/unit/util/fileUtils.test.ts @@ -1,6 +1,9 @@ +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import * as fileUtils from 'preview/util/fileUtils'; describe('FileUtils', () => { + const libraryFiles: LibraryConfigFile[] = []; + it('should return true if some files are empty', () => { const files = [ { name: 'file1', content: '', isNew: true, isModified: false }, @@ -9,11 +12,15 @@ describe('FileUtils', () => { const setErrorMessage = jest.fn(); - const result = fileUtils.validateFiles(files, setErrorMessage); + const result = fileUtils.validateFiles( + files, + libraryFiles, + setErrorMessage, + ); expect(result).toBe(true); expect(setErrorMessage).toHaveBeenCalledWith( - 'The following files have empty content: file1. Edit them in order to create the new digital twin.', + 'The following files have empty content: file1.\n Edit them in order to create the new digital twin.', ); }); @@ -25,7 +32,11 @@ describe('FileUtils', () => { const setErrorMessage = jest.fn(); - const result = fileUtils.validateFiles(files, setErrorMessage); + const result = fileUtils.validateFiles( + files, + libraryFiles, + setErrorMessage, + ); expect(result).toBe(false); expect(setErrorMessage).not.toHaveBeenCalled(); diff --git a/client/test/preview/unit/util/gitlab.test.ts b/client/test/preview/unit/util/gitlab.test.ts index b66d5c43d..0eb5c66a5 100644 --- a/client/test/preview/unit/util/gitlab.test.ts +++ b/client/test/preview/unit/util/gitlab.test.ts @@ -95,16 +95,7 @@ describe('GitlabInstance', () => { const subfolders = await gitlab.getDTSubfolders(projectId); expect(subfolders).toHaveLength(2); - expect(subfolders).toEqual([ - { - name: 'subfolder1', - path: 'digital_twins/subfolder1', - }, - { - name: 'subfolder2', - path: 'digital_twins/subfolder2', - }, - ]); + expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith( projectId, { diff --git a/client/test/preview/unit/util/init.test.ts b/client/test/preview/unit/util/init.test.ts index 02acb61c4..2518cee19 100644 --- a/client/test/preview/unit/util/init.test.ts +++ b/client/test/preview/unit/util/init.test.ts @@ -1,15 +1,17 @@ -import { fetchAssets } from 'preview/util/init'; -import { setAssets } from 'preview/store/assets.slice'; -import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; +import { fetchLibraryAssets } from 'preview/util/init'; import { - mockDigitalTwin, mockGitlabInstance, + mockLibraryAsset, } from 'test/preview/__mocks__/global_mocks'; +jest.mock('preview/util/libraryAsset', () => ({ + default: jest.fn().mockImplementation(() => mockLibraryAsset), +})); + jest.mock('preview/util/gitlab', () => { const mockSimpleGitlabInstance = { init: jest.fn(), - getDTSubfolders: jest.fn(), + getLibrarySubfolders: jest.fn(), projectId: 1, }; @@ -18,16 +20,10 @@ jest.mock('preview/util/gitlab', () => { }; }); -jest.mock('preview/util/digitalTwin', () => ({ - default: jest.fn().mockImplementation(() => mockDigitalTwin), -})); - jest.mock('preview/store/assets.slice', () => ({ + setAsset: jest.fn(), setAssets: jest.fn(), })); -jest.mock('preview/store/digitalTwin.slice', () => ({ - setDigitalTwin: jest.fn(), -})); describe('fetchAssets', () => { const dispatch = jest.fn(); @@ -37,39 +33,12 @@ describe('fetchAssets', () => { jest.clearAllMocks(); }); - it('should fetch assets and create digital twins', async () => { + it('should fetch library assets and set them', async () => { (mockGitlabInstance.init as jest.Mock).mockResolvedValue({}); - (mockGitlabInstance.getDTSubfolders as jest.Mock).mockResolvedValue([ - { name: 'asset1', path: 'path1' }, + (mockGitlabInstance.getLibrarySubfolders as jest.Mock).mockResolvedValue([ + { name: 'asset1', path: 'path1', type: 'models', isPrivate: false }, ]); - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith( - setAssets([{ name: 'asset1', path: 'path1' }]), - ); - expect(dispatch).toHaveBeenCalledWith( - setDigitalTwin({ - assetName: 'asset1', - digitalTwin: mockDigitalTwin, - }), - ); - }); - - it('should handle empty project ID by setting assets to an empty array', async () => { - mockGitlabInstance.projectId = null; - - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith(setAssets([])); - }); - - it('should skip digital twin creation if no assets are found', async () => { - (mockGitlabInstance.init as jest.Mock).mockResolvedValue({}); - (mockGitlabInstance.getDTSubfolders as jest.Mock).mockResolvedValue([]); - - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith(setAssets([])); + await fetchLibraryAssets(dispatch, setError, 'models', true); }); }); diff --git a/client/test/preview/unit/util/libraryAsset.test.ts b/client/test/preview/unit/util/libraryAsset.test.ts new file mode 100644 index 000000000..355981f2a --- /dev/null +++ b/client/test/preview/unit/util/libraryAsset.test.ts @@ -0,0 +1,74 @@ +import LibraryAsset from 'preview/util/libraryAsset'; +import GitlabInstance from 'preview/util/gitlab'; +import LibraryManager from 'preview/util/libraryManager'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/libraryManager'); +jest.mock('preview/util/gitlab'); + +describe('LibraryAsset', () => { + let gitlabInstance: GitlabInstance; + let libraryManager: LibraryManager; + let libraryAsset: LibraryAsset; + + beforeEach(() => { + gitlabInstance = mockGitlabInstance; + libraryManager = new LibraryManager('test', gitlabInstance); + libraryAsset = new LibraryAsset( + 'test', + 'path/to/library', + true, + 'type', + gitlabInstance, + ); + libraryAsset.libraryManager = libraryManager; + }); + + it('should initialize correctly', () => { + expect(libraryAsset.name).toBe('test'); + expect(libraryAsset.path).toBe('path/to/library'); + expect(libraryAsset.isPrivate).toBe(true); + expect(libraryAsset.type).toBe('type'); + expect(libraryAsset.gitlabInstance).toBe(gitlabInstance); + expect(libraryAsset.libraryManager).toBe(libraryManager); + }); + + it('should get description', async () => { + libraryManager.getFileContent = jest.fn().mockResolvedValue('File content'); + await libraryAsset.getDescription(); + expect(libraryAsset.description).toBe('File content'); + }); + + it('should handle error when getting description', async () => { + libraryManager.getFileContent = jest + .fn() + .mockRejectedValue(new Error('Error')); + await libraryAsset.getDescription(); + expect(libraryAsset.description).toBe('There is no description.md file'); + }); + + it('should get full description with image URLs replaced', async () => { + const fileContent = '![alt text](image.png)'; + libraryManager.getFileContent = jest.fn().mockResolvedValue(fileContent); + sessionStorage.setItem('username', 'user'); + await libraryAsset.getFullDescription(); + expect(libraryAsset.fullDescription).toBe( + '![alt text](https://example.com/AUTHORITY/dtaas/user/-/raw/main/path/to/library/image.png)', + ); + }); + + it('should handle error when getting full description', async () => { + libraryManager.getFileContent = jest + .fn() + .mockRejectedValue(new Error('Error')); + await libraryAsset.getFullDescription(); + expect(libraryAsset.fullDescription).toBe('There is no README.md file'); + }); + + it('should get config files', async () => { + const fileNames = ['file1', 'file2']; + libraryManager.getFileNames = jest.fn().mockResolvedValue(fileNames); + await libraryAsset.getConfigFiles(); + expect(libraryAsset.configFiles).toEqual(fileNames); + }); +}); diff --git a/client/test/preview/unit/util/libraryManager.test.ts b/client/test/preview/unit/util/libraryManager.test.ts new file mode 100644 index 000000000..496020661 --- /dev/null +++ b/client/test/preview/unit/util/libraryManager.test.ts @@ -0,0 +1,74 @@ +import LibraryManager, { + getFilePath, + FileType, +} from 'preview/util/libraryManager'; +import GitlabInstance from 'preview/util/gitlab'; +import FileHandler from 'preview/util/fileHandler'; +import { FileState } from 'preview/store/file.slice'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/fileHandler'); +jest.mock('preview/util/gitlab'); + +describe('LibraryManager', () => { + let gitlabInstance: GitlabInstance; + let fileHandler: FileHandler; + let libraryManager: LibraryManager; + + beforeEach(() => { + gitlabInstance = mockGitlabInstance; + fileHandler = new FileHandler('testAsset', gitlabInstance); + libraryManager = new LibraryManager('testAsset', gitlabInstance); + libraryManager.fileHandler = fileHandler; + }); + + it('should initialize correctly', () => { + expect(libraryManager.assetName).toBe('testAsset'); + expect(libraryManager.gitlabInstance).toBe(gitlabInstance); + expect(libraryManager.fileHandler).toBe(fileHandler); + }); + + it('should get file content', async () => { + const fileContent = 'file content'; + fileHandler.getFileContent = jest.fn().mockResolvedValue(fileContent); + + const result = await libraryManager.getFileContent( + true, + 'path/to/file', + 'file.txt', + ); + expect(result).toBe(fileContent); + expect(fileHandler.getFileContent).toHaveBeenCalledWith( + 'path/to/file/file.txt', + true, + ); + }); + + it('should get file names', async () => { + const fileNames = ['file1', 'file2']; + fileHandler.getLibraryConfigFileNames = jest + .fn() + .mockResolvedValue(fileNames); + + const result = await libraryManager.getFileNames(true, 'path/to/files'); + expect(result).toEqual(fileNames); + expect(fileHandler.getLibraryConfigFileNames).toHaveBeenCalledWith( + 'path/to/files', + true, + ); + }); +}); + +describe('getFilePath', () => { + it('should return lifecycle folder path for lifecycle file type', () => { + const file: FileState = { type: FileType.LIFECYCLE } as FileState; + const result = getFilePath(file, 'main/path', 'lifecycle/path'); + expect(result).toBe('lifecycle/path'); + }); + + it('should return main folder path for non-lifecycle file type', () => { + const file: FileState = { type: FileType.DESCRIPTION } as FileState; + const result = getFilePath(file, 'main/path', 'lifecycle/path'); + expect(result).toBe('main/path'); + }); +}); diff --git a/client/test/unit/util/envUtil.test.ts b/client/test/unit/util/envUtil.test.ts index 0cd72dcae..96657d92b 100644 --- a/client/test/unit/util/envUtil.test.ts +++ b/client/test/unit/util/envUtil.test.ts @@ -32,6 +32,7 @@ describe('envUtil', () => { REACT_APP_WORKBENCHLINK_VSCODE: testWorkbenchEndpoints[1], REACT_APP_WORKBENCHLINK_JUPYTERLAB: testWorkbenchEndpoints[2], REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: testWorkbenchEndpoints[3], + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: testWorkbenchEndpoints[4], REACT_APP_WORKBENCHLINK_DT_PREVIEW: testWorkbenchEndpoints[4], REACT_APP_CLIENT_ID: testAppID, diff --git a/client/yarn.lock b/client/yarn.lock index c106a6d14..f4deaf909 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2403,6 +2403,14 @@ lodash "^4.17.21" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.1.tgz#29c0ee878d672703f5e7579f239005e4e0faa875" @@ -4360,6 +4368,13 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" +cross-fetch@^3.0.4: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + 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" @@ -7068,6 +7083,14 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" @@ -8316,6 +8339,13 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -9391,6 +9421,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + promise@^8.1.0: version "8.3.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" @@ -9566,6 +9601,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.11: version "6.0.11" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" @@ -9946,7 +9988,7 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== -reselect@^5.1.0: +reselect@^5.1.0, reselect@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== @@ -11018,6 +11060,11 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -11390,6 +11437,11 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -11566,6 +11618,14 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" diff --git a/deploy/config/client/env.js b/deploy/config/client/env.js index 979860b18..0b76cf5a8 100644 --- a/deploy/config/client/env.js +++ b/deploy/config/client/env.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/deploy/config/client/env.local.js b/deploy/config/client/env.local.js index e989f87bf..c8cb6dd61 100644 --- a/deploy/config/client/env.local.js +++ b/deploy/config/client/env.local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/docs/admin/client/config.md b/docs/admin/client/config.md index 37216acf8..1eb295a77 100644 --- a/docs/admin/client/config.md +++ b/docs/admin/client/config.md @@ -15,6 +15,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_JUPYTERLAB: "Endpoint for the Jupyter Lab link", REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: "Endpoint for the Jupyter Notebook link", + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: "Endpoint fot the Library page preview", REACT_APP_WORKBENCHLINK_DT_PREVIEW: "Endpoint for the Digital Twins page preview", REACT_APP_CLIENT_ID: 'AppID genereated by the gitlab OAuth provider', REACT_APP_AUTH_AUTHORITY: 'URL of the private gitlab instance', @@ -36,6 +37,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', @@ -59,6 +61,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/',