This is an automated email from the ASF dual-hosted git repository. dimuthuupe pushed a commit to branch cybershuttle-staging in repository https://gitbox.apache.org/repos/asf/airavata.git
commit 8d7e4c439b051b8514202117299f884a9a5a613b Author: ganning127 <[email protected]> AuthorDate: Sun Apr 6 22:46:55 2025 -0400 Condense to single resource page + allow unauthenticated routes --- .../research-framework/portal/package-lock.json | 92 ++++++++- modules/research-framework/portal/package.json | 4 +- modules/research-framework/portal/src/App.tsx | 16 +- .../src/components/auth/ProtectedComponent.tsx | 10 +- .../portal/src/components/auth/UserMenu.tsx | 1 + .../portal/src/components/datasets/DatasetCard.tsx | 70 ------- .../src/components/datasets/DatasetDetails.tsx | 54 ----- .../portal/src/components/home/ResourceCard.tsx | 18 +- .../portal/src/components/notebooks/index.tsx | 16 +- .../portal/src/components/resources/TagInput.css | 103 ++++++++++ .../portal/src/components/resources/index.tsx | 226 +++++++++++++++++++++ .../portal/src/layouts/NavBar.tsx | 38 ++-- .../research-framework/research-service/pom.xml | 4 +- .../research/service/config/AuthzTokenFilter.java | 4 +- .../service/config/DevDataInitializer.java | 29 ++- .../service/controller/ResourceController.java | 14 +- .../research/service/handlers/ResourceHandler.java | 12 +- .../service/model/repo/ResourceRepository.java | 16 ++ 18 files changed, 545 insertions(+), 182 deletions(-) diff --git a/modules/research-framework/portal/package-lock.json b/modules/research-framework/portal/package-lock.json index 1184b19f57..27f3a8f938 100644 --- a/modules/research-framework/portal/package-lock.json +++ b/modules/research-framework/portal/package-lock.json @@ -960,6 +960,21 @@ "dev": true, "optional": true }, + "@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "@rollup/rollup-android-arm-eabi": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz", @@ -1180,6 +1195,19 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "@types/lodash": { + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==" + }, + "@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "requires": { + "@types/lodash": "*" + } + }, "@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2450,6 +2478,11 @@ } } }, + "classnames": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==" + }, "cli-table": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", @@ -2564,6 +2597,16 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "requires": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2788,8 +2831,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.3.3", @@ -3294,6 +3336,11 @@ "p-locate": "^5.0.0" } }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3651,6 +3698,26 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==" }, + "react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "requires": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "requires": { + "dnd-core": "^16.0.1" + } + }, "react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", @@ -3691,10 +3758,15 @@ "turbo-stream": "2.4.0" } }, - "react-tag-input-component": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-tag-input-component/-/react-tag-input-component-2.0.2.tgz", - "integrity": "sha512-dydI9luVwwv9vrjE5u1TTnkcOVkOVL6mhFti8r6hLi78V2F2EKWQOLptURz79UYbDHLSk6tnbvGl8FE+sMpADg==" + "react-tag-input": { + "version": "6.10.6", + "resolved": "https://registry.npmjs.org/react-tag-input/-/react-tag-input-6.10.6.tgz", + "integrity": "sha512-oXopRSs2ZSuMqsygcZhjal869MZpAUX0YVNQ/WGaYXPJmbuZIviqeTocs3g/BHs9ReIG9vu563JOZsHGikiMgw==", + "requires": { + "@types/lodash-es": "^4.17.12", + "classnames": "~2.3.1", + "lodash-es": "^4.17.21" + } }, "readdirp": { "version": "3.6.0", @@ -3705,6 +3777,14 @@ "picomatch": "^2.2.1" } }, + "redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", diff --git a/modules/research-framework/portal/package.json b/modules/research-framework/portal/package.json index da96f656f9..65121a7cf7 100644 --- a/modules/research-framework/portal/package.json +++ b/modules/research-framework/portal/package.json @@ -19,11 +19,13 @@ "next-themes": "^0.4.6", "oidc-client-ts": "^3.2.0", "react": "^19.0.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.0.0", "react-icons": "^5.5.0", "react-oidc-context": "^3.3.0", "react-router": "^7.3.0", - "react-tag-input-component": "^2.0.2" + "react-tag-input": "^6.10.6" }, "devDependencies": { "@chakra-ui/cli": "^3.12.0", diff --git a/modules/research-framework/portal/src/App.tsx b/modules/research-framework/portal/src/App.tsx index 32b403aacc..c59ac8ad91 100644 --- a/modules/research-framework/portal/src/App.tsx +++ b/modules/research-framework/portal/src/App.tsx @@ -19,6 +19,7 @@ import { OPENID_CONFIG_URL, } from "./lib/constants"; import { WebStorageStateStore } from "oidc-client-ts"; +import { Resources } from "./components/resources"; function App() { const colorMode = useColorMode(); const navigate = useNavigate(); @@ -73,10 +74,6 @@ function App() { <AuthProvider {...oidcConfig} onSigninCallback={() => { - // const from = location.state?.from || "/"; // fallback to homepage - // console.log("Redirecting to:", from); - // navigate(from, { replace: true }); - // clear state from url navigate(location.pathname, { replace: true }); }} > @@ -85,6 +82,12 @@ function App() { <Route element={<NavBarFooterLayout />}> <Route path="/" element={<CybershuttleLanding />} /> <Route path="/login" element={<Login />} /> + <Route path="/resources" element={<Resources />} /> + <Route path="/resources/datasets" element={<Datasets />} /> + <Route path="/resources/notebooks" element={<Notebooks />} /> + <Route path="/resources/repositories" element={<Repositories />} /> + <Route path="/resources/models" element={<Models />} /> + <Route path="/resources/:type/:id" element={<ResourceDetails />} /> </Route> {/* Protected Routes with Layout */} @@ -92,11 +95,6 @@ function App() { element={<ProtectedComponent Component={NavBarFooterLayout} />} > <Route path="/projects" element={<Home />} /> - <Route path="/resources/notebooks" element={<Notebooks />} /> - <Route path="/resources/datasets" element={<Datasets />} /> - <Route path="/resources/repositories" element={<Repositories />} /> - <Route path="/resources/models" element={<Models />} /> - <Route path="/resources/:type/:id" element={<ResourceDetails />} /> </Route> </Routes> </AuthProvider> diff --git a/modules/research-framework/portal/src/components/auth/ProtectedComponent.tsx b/modules/research-framework/portal/src/components/auth/ProtectedComponent.tsx index d9a27a7c44..ed2685ab9f 100644 --- a/modules/research-framework/portal/src/components/auth/ProtectedComponent.tsx +++ b/modules/research-framework/portal/src/components/auth/ProtectedComponent.tsx @@ -1,25 +1,25 @@ import { setUserProvider } from "@/lib/api"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router"; function ProtectedComponent({ Component }: { Component: React.FC }) { const auth = useAuth(); const navigate = useNavigate(); - const path = window.location.pathname; + const initialPathRef = useRef(window.location.pathname); // store once useEffect(() => { if (!auth.isLoading && !auth.isAuthenticated) { - navigate(`/login?redirect=${path}`, { replace: true }); + navigate(`/login?redirect=${initialPathRef.current}`, { replace: true }); } if (auth.isAuthenticated) { setUserProvider(() => Promise.resolve(auth.user ?? null)); } - }, [auth]); + }, [auth, navigate]); if (auth.isLoading || !auth.isAuthenticated) { - return; + return null; } return <Component />; diff --git a/modules/research-framework/portal/src/components/auth/UserMenu.tsx b/modules/research-framework/portal/src/components/auth/UserMenu.tsx index cba709720f..a92ff98eac 100644 --- a/modules/research-framework/portal/src/components/auth/UserMenu.tsx +++ b/modules/research-framework/portal/src/components/auth/UserMenu.tsx @@ -24,6 +24,7 @@ export const UserMenu = () => { </Link> ); const handleLogout = async () => { + // Clear the user provider await auth.signoutRedirect(); }; diff --git a/modules/research-framework/portal/src/components/datasets/DatasetCard.tsx b/modules/research-framework/portal/src/components/datasets/DatasetCard.tsx deleted file mode 100644 index 95d3a84ddf..0000000000 --- a/modules/research-framework/portal/src/components/datasets/DatasetCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { DatasetType } from "@/interfaces/DatasetType"; -import { - Text, - Box, - Card, - HStack, - Avatar, - Image, - Badge, -} from "@chakra-ui/react"; -import { Link } from "react-router"; - -export const DatasetCard = ({ dataset }: { dataset: DatasetType }) => { - return ( - <Box> - <Card.Root overflow="hidden"> - <Image - src={dataset.metadata.images.headerImage} - maxH={100} - alt="Green double couch with wooden legs" - /> - <Link to={`/datasets/${dataset.metadata.slug}`}> - <Card.Body - gap="2" - _hover={{ - bg: "gray.100", - }} - > - <HStack justifyContent="space-between" mb={1}> - <Card.Title>{dataset.metadata.title}</Card.Title> - <Badge - colorPalette={dataset.private ? "red" : "green"} - rounded="md" - > - {dataset.private ? "Private" : "Public"} - </Badge> - </HStack> - <HStack flexWrap={"wrap"}> - <Badge colorPalette={"purple"} fontWeight="bold" size="md"> - {dataset.metadata.type} - </Badge> - {dataset.metadata.tags.map((tag: string) => ( - <Badge size="md" key={tag}> - {tag} - </Badge> - ))} - </HStack> - <Text color="fg.muted" lineClamp={2}> - {dataset.metadata.description}. - </Text> - </Card.Body> - </Link> - - <Card.Footer justifyContent="space-between"> - <HStack mt={4}> - <Avatar.Root shape="full" size="xl"> - <Avatar.Fallback name={dataset.metadata.author.name} /> - <Avatar.Image src={dataset.metadata.author.avatar} /> - </Avatar.Root> - - <Box> - <Text fontWeight="bold">{dataset.metadata.author.name}</Text> - <Text color="gray.500">{dataset.metadata.author.role}</Text> - </Box> - </HStack> - </Card.Footer> - </Card.Root> - </Box> - ); -}; diff --git a/modules/research-framework/portal/src/components/datasets/DatasetDetails.tsx b/modules/research-framework/portal/src/components/datasets/DatasetDetails.tsx deleted file mode 100644 index 975639fcd6..0000000000 --- a/modules/research-framework/portal/src/components/datasets/DatasetDetails.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Metadata } from "../Metadata"; -import NavBar from "../../layouts/NavBar"; -// @ts-expect-error This is fine -import { MOCK_DATASETS } from "../../data/MOCK_DATA"; -import { DatasetType } from "@/interfaces/DatasetType"; -import { useEffect, useState } from "react"; -import { Badge, Box, Container, HStack, Icon, Spinner } from "@chakra-ui/react"; -import { Link, useParams } from "react-router"; -import { BiArrowBack } from "react-icons/bi"; - -async function getDataset(slug: string | undefined) { - return MOCK_DATASETS.find( - (dataset: DatasetType) => dataset.metadata.slug === slug - ); -} - -export const DatasetDetails = () => { - const [dataset, setDataset] = useState<DatasetType | null>(null); - const { slug } = useParams(); - - useEffect(() => { - async function getData() { - const n = await getDataset(slug); - setDataset(n); - } - getData(); - }, []); - - if (!dataset) return <Spinner />; - return ( - <> - <NavBar /> - - <Container maxW="breakpoint-lg" mx="auto" p={4} mt={16}> - <Box> - <HStack alignItems="center" mb={4}> - <Icon> - <BiArrowBack /> - </Icon> - <Link to="/datasets">Back to Datasets</Link> - </HStack> - </Box> - <Badge - colorPalette={dataset.private ? "red" : "green"} - rounded="md" - mb={1} - > - {dataset.private ? "Private" : "Public"} - </Badge> - <Metadata metadata={dataset.metadata} /> - </Container> - </> - ); -}; diff --git a/modules/research-framework/portal/src/components/home/ResourceCard.tsx b/modules/research-framework/portal/src/components/home/ResourceCard.tsx index a5da829aac..71f937cfdd 100644 --- a/modules/research-framework/portal/src/components/home/ResourceCard.tsx +++ b/modules/research-framework/portal/src/components/home/ResourceCard.tsx @@ -15,15 +15,29 @@ import { ResourceTypeBadge } from "../resources/ResourceTypeBadge"; import { ResourceTypeEnum } from "@/interfaces/ResourceTypeEnum"; import { ModelCardButton } from "../models/ModelCardButton"; -export const ResourceCard = ({ resource }: { resource: Resource }) => { +export const ResourceCard = ({ + resource, + appendTypeToUrl = false, +}: { + resource: Resource; + appendTypeToUrl?: boolean; +}) => { const author = resource.authors[0]; const isValidImage = isValidImaage(resource.headerImage); + resource.tags.sort((a, b) => a.value.localeCompare(b.value)); + + const linkTo = resource.id; + const linkToWithType = `${resource.type}/${resource.id}`; + + // Determine the link based on appendTypeToUrl + const link = appendTypeToUrl ? linkToWithType : linkTo; + return ( <Box> <Card.Root overflow="hidden" size="md"> - <Link to={`${resource.id}`}> + <Link to={link}> {/* Image Container with Badge */} {isValidImage && ( <Box position="relative" width="full"> diff --git a/modules/research-framework/portal/src/components/notebooks/index.tsx b/modules/research-framework/portal/src/components/notebooks/index.tsx index ac0dd44507..cb241577ea 100644 --- a/modules/research-framework/portal/src/components/notebooks/index.tsx +++ b/modules/research-framework/portal/src/components/notebooks/index.tsx @@ -47,13 +47,7 @@ const Notebooks = () => { <InputGroup endElement={<LuSearch />} w="100%" mt={4}> <Input placeholder="Search" rounded="md" /> </InputGroup> - {/* <Box mt={4}> - <TagsInput - value={tags} - onChange={setTags} - placeHolder="Filter by tags" - /> - </Box> */} + <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} mt={4} @@ -61,7 +55,13 @@ const Notebooks = () => { justifyContent="space-around" > {notebooks.map((notebook: NotebookResource) => { - return <ResourceCard resource={notebook} key={notebook.id} />; + return ( + <ResourceCard + resource={notebook} + key={notebook.id} + appendTypeToUrl={false} + /> + ); })} </SimpleGrid> </Container> diff --git a/modules/research-framework/portal/src/components/resources/TagInput.css b/modules/research-framework/portal/src/components/resources/TagInput.css new file mode 100644 index 0000000000..9caa43e09c --- /dev/null +++ b/modules/research-framework/portal/src/components/resources/TagInput.css @@ -0,0 +1,103 @@ +/* TagInput.css */ + +.tag-input-wrapper { + padding: 1rem; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05); +} + +/* container holding all tags + input */ +.ReactTags__tags { + position: relative; + /* Add this line */ + display: flex; + flex-wrap: wrap; + gap: 8px; + border: 2px solid #e0e0e0; + border-radius: 10px; + padding: 10px; + background-color: #fafafa; + transition: border-color 0.2s ease-in-out; +} + +/* tag pill style */ +.ReactTags__tag { + background-color: #007bff; + color: #fff; + padding: 6px 12px; + border-radius: 20px; + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +/* remove (x) icon */ +.ReactTags__remove { + margin-left: 4px; + cursor: pointer; + font-weight: bold; + color: #fff; +} + +/* input field container (flexed) */ +.ReactTags__tagInput { + flex: 1 1 auto; + /* allow it to grow and wrap */ + min-width: 120px; +} + +.ReactTags__selected { + width: 100%; + display: flex; + gap: 0.5rem; +} + +/* actual input element */ +.ReactTags__tagInputField { + width: 100%; + border: none; + outline: none; + font-size: 14px; + background: transparent; + padding: 6px; + box-sizing: border-box; +} + +.ReactTags__tagInputField::placeholder { + color: #888; +} + +/* Make suggestions dropdown absolutely positioned */ +.ReactTags__suggestions { + position: absolute; + top: 100%; + /* Push below the input */ + left: 0; + width: 100%; + /* Match parent width */ + z-index: 10; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + max-height: 200px; + overflow-y: auto; + list-style: none; + margin-top: 4px; + padding: 0; +} + +/* Each individual suggestion */ +.ReactTags__suggestions li { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +/* Active (highlighted) suggestion */ +.ReactTags__activeSuggestion { + background-color: #007bff; + color: white; +} \ No newline at end of file diff --git a/modules/research-framework/portal/src/components/resources/index.tsx b/modules/research-framework/portal/src/components/resources/index.tsx new file mode 100644 index 0000000000..504ed2899e --- /dev/null +++ b/modules/research-framework/portal/src/components/resources/index.tsx @@ -0,0 +1,226 @@ +import { + Text, + Container, + Heading, + Box, + SimpleGrid, + Button, + HStack, + Code, +} from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { SEPARATORS, WithContext as ReactTags, Tag } from "react-tag-input"; +import "./TagInput.css"; // 👈 custom styles +import api from "@/lib/api"; +import { CONTROLLER } from "@/lib/controller"; +import { ResourceTypeEnum } from "@/interfaces/ResourceTypeEnum"; +import { Resource } from "@/interfaces/ResourceType"; +import { ResourceCard } from "../home/ResourceCard"; +import { FaCheck } from "react-icons/fa"; +import { Tag as TagEntity } from "@/interfaces/TagType"; + +const getResources = async ( + types: ResourceTypeEnum[], + stringTagsArr: string[] +) => { + try { + const response = await api.get(`${CONTROLLER.resources}/`, { + params: { + type: types.join(","), + tag: stringTagsArr.join(","), + pageNumber: 0, + pageSize: 100, + }, + }); + const data = response.data; + return data; + } catch (error) { + console.error("Error fetching:", error); + } +}; + +const getTags = async () => { + try { + const response = await api.get(`${CONTROLLER.resources}/tags/all`); + const data = response.data; + return data; + } catch (error) { + console.error("Error fetching:", error); + } +}; + +export const Resources = () => { + const [tags, setTags] = useState<Tag[]>([]); + const [suggestions, setSuggestions] = useState<Tag[]>([]); + const [resourceTypes, setResourceTypes] = useState<ResourceTypeEnum[]>([ + ResourceTypeEnum.DATASET, + ResourceTypeEnum.REPOSITORY, + ResourceTypeEnum.NOTEBOOK, + ResourceTypeEnum.MODEL, + ]); + const [resources, setResources] = useState<Resource[]>([]); + + const handleDelete = (i: number) => { + setTags(tags.filter((tag, index) => index !== i)); + }; + + const handleAddition = (tag: Tag) => { + console.log(tag); + setTags([...tags, tag]); + }; + + useEffect(() => { + async function fetchResources() { + const stringTagsArr = tags.map((tag) => tag.text); + const resources = await getResources(resourceTypes, stringTagsArr); + setResources(resources.content); + } + + fetchResources(); + }, [resourceTypes, tags]); + + useEffect(() => { + async function fetchTags() { + const tags: TagEntity[] = await getTags(); + const suggestedTags = tags.map((tag: TagEntity) => ({ + id: tag.value, + text: tag.value, + className: "", + })); + + setSuggestions(suggestedTags); + } + + fetchTags(); + }, []); + + return ( + <> + <Container maxW="container.lg" mt={8}> + <Heading + textAlign="center" + fontSize={{ base: "4xl", md: "5xl" }} + fontWeight="black" + lineHeight={1.2} + > + Browse datasets, repositories, notebooks, and models, + <Text as="span" color="blue.600"> + {" "} + made by scientists, for scientists + </Text> + . + </Heading> + + <Box mt={4} maxWidth="1000px" mx="auto"> + <ReactTags + tags={tags} + handleDelete={handleDelete} + handleAddition={handleAddition} + suggestions={suggestions} + separators={[ + SEPARATORS.TAB, + SEPARATORS.COMMA, + SEPARATORS.ENTER, + SEPARATORS.SEMICOLON, + ]} + allowDragDrop={true} + placeholder="Filter resources by tags" + renderSuggestion={(item) => { + return <span>{item.text}</span>; + }} + /> + + <HStack alignItems="center" mt={2}> + <Text fontSize="sm" color="gray.500" fontWeight="bold"> + Showing + </Text> + <HStack wrap="wrap"> + {Object.values(ResourceTypeEnum).map((type) => { + const isSelected = resourceTypes.includes(type); + return ( + <Button + key={type} + variant="outline" + color={isSelected ? "blue.600" : "black"} + bg={isSelected ? "blue.100" : "white"} + _hover={{ + bg: isSelected ? "blue.200" : "gray.100", + color: isSelected ? "blue.700" : "black", + }} + size="sm" + onClick={() => { + if (isSelected) { + setResourceTypes( + resourceTypes.filter((t) => t !== type) + ); + } else { + setResourceTypes([...resourceTypes, type]); + } + }} + > + {type} + {isSelected && <FaCheck color="blue" />} + </Button> + ); + })} + </HStack> + </HStack> + </Box> + + <SimpleGrid + columns={{ base: 1, md: 2, lg: 3 }} + mt={4} + gap={4} + justifyContent="space-around" + > + {resources.map((resource: Resource) => { + return ( + <ResourceCard + resource={resource} + key={resource.id} + appendTypeToUrl={true} + /> + ); + })} + </SimpleGrid> + + {resources.length === 0 && ( + <Box textAlign="center" color="gray.500"> + <Text textAlign="center" mt={8} mb={4}> + No resources found with the following criteria: + </Text> + <Text> + Tags:{" "} + {tags.length > 0 ? ( + <> + {tags.map((tag) => ( + <Code key={tag.id} colorScheme="blue" mr={1}> + {tag.text} + </Code> + ))} + </> + ) : ( + <Text as="span">None</Text> + )} + </Text> + + <Text> + Resource Types:{" "} + {resourceTypes.length > 0 ? ( + <> + {resourceTypes.map((type) => ( + <Code key={type} colorScheme="blue" mr={1}> + {type} + </Code> + ))} + </> + ) : ( + <Text as="span">None</Text> + )} + </Text> + </Box> + )} + </Container> + </> + ); +}; diff --git a/modules/research-framework/portal/src/layouts/NavBar.tsx b/modules/research-framework/portal/src/layouts/NavBar.tsx index 10b5537f49..016adb640c 100644 --- a/modules/research-framework/portal/src/layouts/NavBar.tsx +++ b/modules/research-framework/portal/src/layouts/NavBar.tsx @@ -20,24 +20,28 @@ import { UserMenu } from "@/components/auth/UserMenu"; const NAV_CONTENT = [ { - title: "Projects", - url: "/projects", - }, - { - title: "Datasets", - url: "/resources/datasets", - }, - { - title: "Repositories", - url: "/resources/repositories", - }, - { - title: "Notebooks", - url: "/resources/notebooks", + title: "Resources", + url: "/resources", }, + // { + // title: "Datasets", + // url: "/resources/datasets", + // }, + // { + // title: "Repositories", + // url: "/resources/repositories", + // }, + // { + // title: "Notebooks", + // url: "/resources/notebooks", + // }, + // { + // title: "Models", + // url: "/resources/models", + // }, { - title: "Models", - url: "/resources/models", + title: "Projects", + url: "/projects", }, ]; @@ -82,7 +86,7 @@ const NavBar = () => { </IconButton> {/* Logo */} - <Link to="/projects"> + <Link to="/"> <Image src={ApacheAiravataLogo} alt="Logo" boxSize="30px" /> </Link> diff --git a/modules/research-framework/research-service/pom.xml b/modules/research-framework/research-service/pom.xml index d79af13145..0cea8263b8 100644 --- a/modules/research-framework/research-service/pom.xml +++ b/modules/research-framework/research-service/pom.xml @@ -178,8 +178,8 @@ <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> - <source>14</source> - <target>14</target> + <source>15</source> + <target>15</target> </configuration> </plugin> <plugin> diff --git a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/AuthzTokenFilter.java b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/AuthzTokenFilter.java index d31cc28c37..f2c15f0ec5 100644 --- a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/AuthzTokenFilter.java +++ b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/AuthzTokenFilter.java @@ -56,12 +56,14 @@ public class AuthzTokenFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestURI(); + // TODO: ensure that only GET requests do not need auth return path.startsWith("/swagger") || path.startsWith("/v2/api-docs") || path.startsWith("/v3/api-docs") || path.startsWith("/swagger-ui") || path.startsWith("/swagger-resources") || - path.startsWith("/webjars/"); + path.startsWith("/webjars/") || + path.startsWith("/api/v1/rf/resources"); } @Override diff --git a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/DevDataInitializer.java b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/DevDataInitializer.java index 35614509c7..816fa83e72 100644 --- a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/DevDataInitializer.java +++ b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/config/DevDataInitializer.java @@ -18,14 +18,18 @@ package org.apache.airavata.research.service.config; import java.util.HashSet; +import java.util.Optional; +import java.util.Set; import org.apache.airavata.research.service.enums.PrivacyEnum; import org.apache.airavata.research.service.enums.StatusEnum; import org.apache.airavata.research.service.model.entity.DatasetResource; import org.apache.airavata.research.service.model.entity.Project; import org.apache.airavata.research.service.model.entity.RepositoryResource; +import org.apache.airavata.research.service.model.entity.Tag; import org.apache.airavata.research.service.model.repo.ProjectRepository; import org.apache.airavata.research.service.model.repo.ResourceRepository; +import org.apache.airavata.research.service.model.repo.TagRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; @@ -37,16 +41,31 @@ public class DevDataInitializer implements CommandLineRunner { private final ProjectRepository projectRepository; private final ResourceRepository resourceRepository; + private final TagRepository tagRepository; @Value("${airavata.research-hub.dev-user}") private String devUserEmail; - public DevDataInitializer(ProjectRepository projectRepository, ResourceRepository resourceRepository) { + public DevDataInitializer(ProjectRepository projectRepository, ResourceRepository resourceRepository, TagRepository tagRepository) { this.projectRepository = projectRepository; this.resourceRepository = resourceRepository; + this.tagRepository = tagRepository; } - private void createProject(String name, String repoUrl, String datasetName, String datasetUrl, String user) { + private void createProject(String name, String repoUrl, String datasetName, String datasetUrl, String[] tags, String user) { + Set<Tag> tagSet = new HashSet<>(); + for (String tag : tags) { + Tag t = tagRepository.findByValue(tag); + if (t != null) { + tagSet.add(t); + } else { + Tag newTag = new Tag(); + newTag.setValue(tag); + tagSet.add(newTag); + tagRepository.save(newTag); + } + } + RepositoryResource repo = new RepositoryResource(); repo.setName(name); repo.setDescription("Repository for " + name); @@ -54,6 +73,7 @@ public class DevDataInitializer implements CommandLineRunner { repo.setRepositoryUrl(repoUrl); repo.setStatus(StatusEnum.VERIFIED); repo.setPrivacy(PrivacyEnum.PUBLIC); + repo.setTags(tagSet); repo = resourceRepository.save(repo); DatasetResource dataset = new DatasetResource(); @@ -63,6 +83,7 @@ public class DevDataInitializer implements CommandLineRunner { dataset.setDatasetUrl(datasetUrl); dataset.setStatus(StatusEnum.VERIFIED); dataset.setPrivacy(PrivacyEnum.PUBLIC); + dataset.setTags(tagSet); dataset.setAuthors(new HashSet<>() { { add(user); @@ -92,6 +113,7 @@ public class DevDataInitializer implements CommandLineRunner { "https://github.com/yasithdev/bmtk-workshop.git", "Allen / BMTK Workshop Data", "allen-bmtk-workshop", + new String[]{"allen", "bmtk", "workshop"}, devUserEmail ); @@ -100,6 +122,7 @@ public class DevDataInitializer implements CommandLineRunner { "https://github.com/yasithdev/allen-v1.git", "Allen / V1 Data", "allen-v1", + new String[]{"allen", "v1", "workshop"}, devUserEmail ); @@ -108,6 +131,7 @@ public class DevDataInitializer implements CommandLineRunner { "https://github.com/yasithdev/onehot-hmmglm.git", "BRAINML / OneHot HMMGLM Data", "brainml-onehot-hmmglm", + new String[]{"brainml", "onehot", "workshop"}, devUserEmail ); @@ -116,6 +140,7 @@ public class DevDataInitializer implements CommandLineRunner { "https://github.com/yasithdev/functional-network.git", "HChoiLab / Functional Network Data", "hchoilab-functional-network", + new String[]{"hchoilab", "functional network", "workshop"}, devUserEmail ); } diff --git a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/controller/ResourceController.java b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/controller/ResourceController.java index 9f4228b554..d7116476d2 100644 --- a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/controller/ResourceController.java +++ b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/controller/ResourceController.java @@ -73,6 +73,14 @@ public class ResourceController { return ResponseEntity.ok(response); } + @Operation( + summary = "Get all tags" + ) + @GetMapping(value = "/tags/all") + public ResponseEntity<List<org.apache.airavata.research.service.model.entity.Tag>> getTags() { + return ResponseEntity.ok(resourceHandler.getAllTags()); + } + @Operation( summary = "Get dataset, notebook, or repository" ) @@ -86,10 +94,10 @@ public class ResourceController { ) @GetMapping("/") public ResponseEntity<Page<Resource>> getAllResources( - @RequestHeader(name="X-Claims", required=true) String claims, @RequestParam(value="pageNumber", defaultValue = "0") int pageNumber, @RequestParam(value="pageSize", defaultValue = "10") int pageSize, - @RequestParam(value="type") ResourceTypeEnum[] types + @RequestParam(value="type") ResourceTypeEnum[] types, + @RequestParam(value="tag", required = false) String[] tags ) { List<Class<? extends Resource>> typeList = new ArrayList<>(); for (ResourceTypeEnum resourceType : types) { @@ -103,7 +111,7 @@ public class ResourceController { typeList.add(DatasetResource.class); } } - Page<Resource> response = resourceHandler.getAllResources(pageNumber, pageSize, typeList); + Page<Resource> response = resourceHandler.getAllResources(pageNumber, pageSize, typeList, tags); return ResponseEntity.ok(response); } diff --git a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/handlers/ResourceHandler.java b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/handlers/ResourceHandler.java index 85587ea507..72c36bb19c 100644 --- a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/handlers/ResourceHandler.java +++ b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/handlers/ResourceHandler.java @@ -100,9 +100,17 @@ public class ResourceHandler { return opResource.get(); } - public Page<Resource> getAllResources(int pageNumber, int pageSize, List<Class<? extends Resource>> typeList) { + public Page<Resource> getAllResources(int pageNumber, int pageSize, List<Class<? extends Resource>> typeList, String[] tag) { Pageable pageable = PageRequest.of(pageNumber, pageSize); - return resourceRepository.findAllByTypes(typeList, pageable); + if (tag == null || tag.length == 0) { + return resourceRepository.findAllByTypes(typeList, pageable); + } + + return resourceRepository.findAllByTypesAndAllTags(typeList, tag, tag.length, pageable); + } + + public List<Tag> getAllTags() { + return tagRepository.findAll(); } // Helper methods diff --git a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/model/repo/ResourceRepository.java b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/model/repo/ResourceRepository.java index 72daa3c787..95c8205ce2 100644 --- a/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/model/repo/ResourceRepository.java +++ b/modules/research-framework/research-service/src/main/java/org/apache/airavata/research/service/model/repo/ResourceRepository.java @@ -33,4 +33,20 @@ public interface ResourceRepository extends JpaRepository<Resource, String> { @Query("SELECT r FROM #{#entityName} r WHERE TYPE(r) IN :types") Page<Resource> findAllByTypes(@Param("types") List<Class<? extends Resource>> types, Pageable pageable); + + @Query(""" + SELECT r + FROM Resource r + JOIN r.tags t + WHERE r.class IN :typeList AND t.value IN :tags + GROUP BY r + HAVING COUNT(DISTINCT t.value) = :tagCount + """) + Page<Resource> findAllByTypesAndAllTags( + @Param("typeList") List<Class<? extends Resource>> typeList, + @Param("tags") String[] tags, + @Param("tagCount") long tagCount, + Pageable pageable + ); + }
