diff --git a/api-admin/src/main/java/bio/terra/pearl/api/admin/controller/SiteImageController.java b/api-admin/src/main/java/bio/terra/pearl/api/admin/controller/SiteImageController.java index 9295a64ce8..c1dcddeb4d 100644 --- a/api-admin/src/main/java/bio/terra/pearl/api/admin/controller/SiteImageController.java +++ b/api-admin/src/main/java/bio/terra/pearl/api/admin/controller/SiteImageController.java @@ -30,10 +30,15 @@ public ResponseEntity get( private ResponseEntity convertToResourceResponse(Optional imageOpt) { if (imageOpt.isPresent()) { + MediaType contentType; SiteImage image = imageOpt.get(); - MediaType contentType = - MediaType.parseMediaType( - URLConnection.guessContentTypeFromName(image.getCleanFileName())); + if (image.getCleanFileName().endsWith(".ico")) { + contentType = MediaType.valueOf("image/x-icon"); + } else { + contentType = + MediaType.parseMediaType( + URLConnection.guessContentTypeFromName(image.getCleanFileName())); + } return ResponseEntity.ok() .contentType(contentType) .body(new ByteArrayResource(image.getData())); diff --git a/api-admin/src/main/java/bio/terra/pearl/api/admin/service/portal/PortalExtService.java b/api-admin/src/main/java/bio/terra/pearl/api/admin/service/portal/PortalExtService.java index 94b01b4b5a..bc489befa0 100644 --- a/api-admin/src/main/java/bio/terra/pearl/api/admin/service/portal/PortalExtService.java +++ b/api-admin/src/main/java/bio/terra/pearl/api/admin/service/portal/PortalExtService.java @@ -37,10 +37,13 @@ public Portal fullLoad(AdminUser user, String portalShortcode) { return portalService.fullLoad(portal, "en"); } + /** gets all the portals the user has access to, and attaches the corresponding studies */ public List getAll(AdminUser user) { // no additional auth checks needed -- the underlying service filters out portals the user does // not have access to - return portalService.findByAdminUser(user); + List portals = portalService.findByAdminUser(user); + portalService.attachStudies(portals); + return portals; } public PortalEnvironmentConfig updateConfig( diff --git a/core/src/main/java/bio/terra/pearl/core/dao/portal/PortalDao.java b/core/src/main/java/bio/terra/pearl/core/dao/portal/PortalDao.java index 94cb982f54..b825bdbfab 100644 --- a/core/src/main/java/bio/terra/pearl/core/dao/portal/PortalDao.java +++ b/core/src/main/java/bio/terra/pearl/core/dao/portal/PortalDao.java @@ -91,6 +91,16 @@ public Portal fullLoad(Portal portal, String language) { return portal; } + public void attachStudies(List portals) { + List portalIds = portals.stream().map(portal -> portal.getId()).toList(); + List portalStudies = portalStudyDao.findByPortalIds(portalIds); + portalStudyDao.attachStudies(portalStudies); + for(Portal portal : portals) { + List matches = portalStudies.stream().filter(portalStudy -> portalStudy.getPortalId().equals(portal.getId())).toList(); + portal.getPortalStudies().addAll(matches); + } + } + public List findByAdminUserId(UUID userId) { List portalAdmins = portalAdminUserDao.findByUserId(userId); return findAll(portalAdmins.stream().map(PortalAdminUser::getPortalId).toList()); diff --git a/core/src/main/java/bio/terra/pearl/core/dao/study/PortalStudyDao.java b/core/src/main/java/bio/terra/pearl/core/dao/study/PortalStudyDao.java index 5f09d50ac4..0d5fe81578 100644 --- a/core/src/main/java/bio/terra/pearl/core/dao/study/PortalStudyDao.java +++ b/core/src/main/java/bio/terra/pearl/core/dao/study/PortalStudyDao.java @@ -5,13 +5,17 @@ import java.util.List; import java.util.Optional; import java.util.UUID; + +import bio.terra.pearl.core.model.study.Study; import org.jdbi.v3.core.Jdbi; import org.springframework.stereotype.Component; @Component public class PortalStudyDao extends BaseJdbiDao { - public PortalStudyDao(Jdbi jdbi) { + private StudyDao studyDao; + public PortalStudyDao(Jdbi jdbi, StudyDao studyDao) { super(jdbi); + this.studyDao = studyDao; } @Override protected Class getClazz() { @@ -25,6 +29,9 @@ public List findByStudyId(UUID studyId) { public List findByPortalId(UUID portalId) { return findAllByProperty("portal_id", portalId); } + public List findByPortalIds(List portalIds) { + return findAllByPropertyCollection("portal_id", portalIds); + } /** gets a list of PortalStudies corresponding to an Enrollee. Enrollees are specific to a single Study, * so this will only return multiple results if that Study is in multiple Portals @@ -52,6 +59,15 @@ public Optional findStudyInPortal(String studyShortcode, UUID porta .findOne()); } + public void attachStudies(List portalStudies) { + List ids = portalStudies.stream().map(ps -> ps.getStudyId()).toList(); + List studies = studyDao.findAll(ids); + for(PortalStudy portalStudy : portalStudies) { + portalStudy.setStudy(studies.stream().filter(study -> study.getId().equals(portalStudy.getStudyId())) + .findFirst().get()); + } + } + public void deleteByPortalId(UUID portalId) { deleteByProperty("portal_id", portalId); } diff --git a/core/src/main/java/bio/terra/pearl/core/service/portal/PortalService.java b/core/src/main/java/bio/terra/pearl/core/service/portal/PortalService.java index 43c42203f7..a9bf659ec9 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/portal/PortalService.java +++ b/core/src/main/java/bio/terra/pearl/core/service/portal/PortalService.java @@ -139,6 +139,10 @@ public List findByAdminUser(AdminUser user) { return dao.findByAdminUserId(user.getId()); } + public void attachStudies(List portals) { + dao.attachStudies(portals); + } + public boolean checkAdminIsInPortal(AdminUser user, UUID portalId) { return user.isSuperuser() || portalAdminUserDao.isUserInPortal(user.getId(), portalId); } diff --git a/populate/lombok.config b/populate/lombok.config index 6aa51d71ec..a284c48a2c 100644 --- a/populate/lombok.config +++ b/populate/lombok.config @@ -1,2 +1,3 @@ # This file is generated by the 'io.freefair.lombok' Gradle plugin config.stopBubbling = true +lombok.extern.findbugs.addSuppressFBWarnings = true diff --git a/populate/src/main/resources/seed/portals/hearthive/images/favicon.ico b/populate/src/main/resources/seed/portals/hearthive/images/favicon.ico new file mode 100644 index 0000000000..cdaca20ad9 Binary files /dev/null and b/populate/src/main/resources/seed/portals/hearthive/images/favicon.ico differ diff --git a/populate/src/main/resources/seed/portals/hearthive/portal.json b/populate/src/main/resources/seed/portals/hearthive/portal.json index b2584d49c9..11ad15bf04 100644 --- a/populate/src/main/resources/seed/portals/hearthive/portal.json +++ b/populate/src/main/resources/seed/portals/hearthive/portal.json @@ -52,12 +52,15 @@ } ], "siteImageDtos": [{ - "populateFileName": "images/main_banner.webp" - }, + "populateFileName": "images/main_banner.webp" + }, { "populateFileName": "images/participant_smile.webp" }, { "populateFileName": "images/HEX_Round.webp" - }] + }, + { + "populateFileName": "images/favicon.ico" + }] } diff --git a/ui-admin/src/App.tsx b/ui-admin/src/App.tsx index ab34ae3f95..9cb121d124 100644 --- a/ui-admin/src/App.tsx +++ b/ui-admin/src/App.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense, useContext } from 'react' +import React, { lazy, Suspense } from 'react' import 'react-notifications-component/dist/theme.css' import 'styles/notifications.css' import 'survey-core/defaultV2.min.css' @@ -9,9 +9,8 @@ import { ReactNotifications } from 'react-notifications-component' import { RedirectFromOAuth } from 'login/RedirectFromOAuth' import { ProtectedRoute } from 'login/ProtectedRoute' -import NavbarProvider, { NavbarContext } from 'navbar/NavbarProvider' import AdminNavbar from 'navbar/AdminNavbar' -import PortalList from 'portal/PortalList' +import HomePage from 'HomePage' import PortalProvider from 'portal/PortalProvider' import UserProvider from 'user/UserProvider' import ConfigProvider, { ConfigConsumer } from 'providers/ConfigProvider' @@ -23,6 +22,8 @@ import InvestigatorTermsOfUsePage from './terms/InvestigatorTermsOfUsePage' import PrivacyPolicyPage from 'terms/PrivacyPolicyPage' import { IdleStatusMonitor } from 'login/IdleStatusMonitor' import LoadingSpinner from './util/LoadingSpinner' +import AdminSidebar from './navbar/AdminSidebar' +import NavContextProvider from 'navbar/NavContextProvider' const HelpRouter = lazy(() => import('./help/HelpRouter')) @@ -37,27 +38,26 @@ function App() {
- - - - }> - }> - - } /> - }> - }/> - }/> - }/> - - - } /> - } /> - Unknown page
}/> + + + + }> + + } /> + + + }> + }/> + }/> + }/> - }/> - - - + } /> + } /> + Unknown page}/> + + }/> + + @@ -69,12 +69,14 @@ function App() { /** Renders the navbar and footer for the page */ function PageFrame() { - const navContext = useContext(NavbarContext) return ( - <> - - - +
+ +
+ + +
+
) } export default App diff --git a/ui-admin/src/HomePage.tsx b/ui-admin/src/HomePage.tsx new file mode 100644 index 0000000000..8149a5c7dd --- /dev/null +++ b/ui-admin/src/HomePage.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { studyParticipantsPath } from './portal/PortalRouter' +import { useNavContext } from './navbar/NavContextProvider' +import { getImageUrl } from './api/api' + +/** Shows a user the list of portals available to them */ +function HomePage() { + const { portalList } = useNavContext() + + return
+

Juniper Home

+
+

My Studies

+
    + { portalList.flatMap(portal => + portal.portalStudies.map(portalStudy => { + const study = portalStudy.study + return
  • + + + {study.name} + +
  • + }) + )} +
+
+
+} + +export default HomePage diff --git a/ui-admin/src/api/api.tsx b/ui-admin/src/api/api.tsx index 85a8ba9ef5..c2e8a13d75 100644 --- a/ui-admin/src/api/api.tsx +++ b/ui-admin/src/api/api.tsx @@ -802,11 +802,21 @@ export default { } } +/** gets an image url for a SiteImage suitable for including in an img tag */ +export function getImageUrl(portalShortcode: string, cleanFileName: string, version: number) { + return `${basePublicPortalEnvUrl(portalShortcode, 'live')}/siteImages/${version}/${cleanFileName}` +} + /** base api path for study-scoped api requests */ function basePortalEnvUrl(portalShortcode: string, envName: string) { return `${API_ROOT}/portals/v1/${portalShortcode}/env/${envName}` } +/** base api path for study-scoped api requests */ +function basePublicPortalEnvUrl(portalShortcode: string, envName: string) { + return `${API_ROOT}/public/portals/v1/${portalShortcode}/env/${envName}` +} + /** base api path for study-scoped api requests */ function baseStudyUrl(portalShortcode: string, studyShortcode: string) { return `${API_ROOT}/portals/v1/${portalShortcode}/studies/${studyShortcode}` diff --git a/ui-admin/src/index.scss b/ui-admin/src/index.scss index 5d23fc208e..e654a2b624 100644 --- a/ui-admin/src/index.scss +++ b/ui-admin/src/index.scss @@ -37,3 +37,11 @@ code { opacity: $form-check-label-disabled-opacity; } } + +[aria-expanded="false"] .hidden-when-collapsed { + display: none; +} + +[aria-expanded="true"] .hidden-when-expanded { + display: none; +} \ No newline at end of file diff --git a/ui-admin/src/navbar/AdminNavbar.test.tsx b/ui-admin/src/navbar/AdminNavbar.test.tsx index 07bb76bc83..12f51a0454 100644 --- a/ui-admin/src/navbar/AdminNavbar.test.tsx +++ b/ui-admin/src/navbar/AdminNavbar.test.tsx @@ -5,12 +5,11 @@ import { mockAdminUser, MockUserProvider } from 'test-utils/user-mocking-utils' import { render, screen } from '@testing-library/react' import AdminNavbar from './AdminNavbar' import userEvent from '@testing-library/user-event' -import { emptyNavbarContext } from './NavbarProvider' test('renders the help menu', async () => { const { RoutedComponent } = setupRouterTest( - + ) render(RoutedComponent) expect(screen.getByTitle('help menu')).toBeInTheDocument() @@ -24,7 +23,7 @@ test('renders the user menu', async () => { ...mockAdminUser(false), username: 'testuser123' }}> - + ) render(RoutedComponent) expect(screen.getByTitle('user menu')).toBeInTheDocument() diff --git a/ui-admin/src/navbar/AdminNavbar.tsx b/ui-admin/src/navbar/AdminNavbar.tsx index cc997d0f39..a7fb7e4191 100644 --- a/ui-admin/src/navbar/AdminNavbar.tsx +++ b/ui-admin/src/navbar/AdminNavbar.tsx @@ -1,189 +1,92 @@ -import React, { useContext, useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { UserContextT, useUser } from 'user/UserProvider' -import { faBars } from '@fortawesome/free-solid-svg-icons/faBars' -import { Link, NavLink, NavLinkProps } from 'react-router-dom' -import { NavbarContext, NavbarContextT } from './NavbarProvider' -import { faQuestionCircle, faUserCircle } from '@fortawesome/free-solid-svg-icons' +import { Link } from 'react-router-dom' +import { useNavContext } from './NavContextProvider' +import { faChevronRight, faQuestionCircle, faUserCircle } from '@fortawesome/free-solid-svg-icons' import ContactSupportInfoModal from '../help/ContactSupportInfoModal' /** note we name this adminNavbar to avoid naming conflicts with bootstrap navbar */ -function AdminNavbar({ breadCrumbs, sidebarContent, showSidebar, setShowSidebar }: NavbarContextT) { +function AdminNavbar() { + const { breadCrumbs } = useNavContext() const currentUser: UserContextT = useUser() - const sidebarRef = useRef(null) - const sidebarToggleRef = useRef(null) const [showContactModal, setShowContactModal] = useState(false) - if (!breadCrumbs) { - breadCrumbs = [] - } - - /** Add a handler so that clicks outside the sidebar hide the sidebar */ - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - // exclude clicks inside the sidebar or of the toggle button - if (sidebarRef.current && !sidebarRef.current.contains(event.target as HTMLElement) && - sidebarToggleRef.current && !sidebarToggleRef.current.contains(event.target as HTMLElement)) { - setShowSidebar(false) - } - } - document.addEventListener('click', handleClickOutside, true) - return () => { - document.removeEventListener('click', handleClickOutside, true) - } - }, []) - + if (currentUser.user.isAnonymous) { + return
+ } return <> - - {showSidebar && ( -
-
- {sidebarContent && sidebarContent.map((content, index) => ( -
- {content} +
- )} + { showContactModal && setShowContactModal(false)}/> } } -/** - * Component for adding a sidebar item when a component is rendered. - * The content will be removed when the component is. - * This component does not render anything directly, but is still structured as a component rather than a pure hook - * so that order rendering will be in-order rather than reversed. See https://github.com/facebook/react/issues/15281 - * */ -export function SidebarContent({ children }: {children: React.ReactNode}) { - const navContext = useContext(NavbarContext) - useEffect(() => { - /** use the setState arg that takes a function to avoid race conditions */ - navContext.setSidebarContent((oldContent: React.ReactNode[]) => { - return [...oldContent, children] - }) - /** return the function that will remove the breadcrumb */ - return () => { - navContext.setSidebarContent((oldCrumbs: React.ReactNode[]) => { - return oldCrumbs.slice(0, -1) - }) - } - }, []) - return null -} - -/** renders a link in the sidebar with appropriate style and onClick handler to close the sidebar when clicked */ -export function SidebarNavLink(props: NavLinkProps) { - const { setShowSidebar } = useContext(NavbarContext) - return ( - setShowSidebar(false)} - style={{ ...props.style, color: '#fff' }} - /> - ) -} - /** * Component for adding a breadcrumb into the navbar when a component is rendered. + * 'value' is used to determine whether the crumb needs updating + * * The breadcrumb will be removed when the component is. * This component does not render anything directly, but is still structured as a component rather than a pure hook * so that order rendering will be in-order rather than reversed. See https://github.com/facebook/react/issues/15281 * */ -export function NavBreadcrumb({ children }: {children: React.ReactNode}) { - const navContext = useContext(NavbarContext) +export function NavBreadcrumb({ value, children }: {value: string, children: React.ReactNode}) { + const { setBreadCrumbs } = useNavContext() useEffect(() => { /** use the setState arg that takes a function to avoid race conditions */ - navContext.setBreadCrumbs((oldCrumbs: React.ReactNode[]) => { + setBreadCrumbs((oldCrumbs: React.ReactNode[]) => { return [...oldCrumbs, children] }) /** return the function that will remove the breadcrumb */ return () => { - navContext.setBreadCrumbs((oldCrumbs: React.ReactNode[]) => { + setBreadCrumbs((oldCrumbs: React.ReactNode[]) => { return oldCrumbs.slice(0, -1) }) } - }, []) + }, [value]) return null } diff --git a/ui-admin/src/navbar/AdminSidebar.test.tsx b/ui-admin/src/navbar/AdminSidebar.test.tsx new file mode 100644 index 0000000000..feb16e6561 --- /dev/null +++ b/ui-admin/src/navbar/AdminSidebar.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' + +import { setupRouterTest } from 'test-utils/router-testing-utils' +import { mockAdminUser, MockUserProvider } from 'test-utils/user-mocking-utils' +import { render, screen, waitFor } from '@testing-library/react' +import AdminSidebar from './AdminSidebar' +import userEvent from '@testing-library/user-event' + +test('renders the superuser menu for superusers', async () => { + const { RoutedComponent } = setupRouterTest( + + + ) + render(RoutedComponent) + expect(screen.getByText('Superuser functions')).toBeInTheDocument() +}) + +test('menu components collapse on click', async () => { + const { RoutedComponent } = setupRouterTest( + + + ) + render(RoutedComponent) + expect(screen.getByText('All users')).toBeVisible() + await userEvent.click(screen.getByText('Superuser functions')) + waitFor(() => expect(screen.queryByText('All users')).not.toBeVisible()) + await userEvent.click(screen.getByText('Superuser functions')) + waitFor(() => expect(screen.queryByText('All users')).toBeVisible()) +}) + +test('does not render the superuser menu for regular users', async () => { + const { RoutedComponent } = setupRouterTest( + + + ) + render(RoutedComponent) + expect(screen.queryByText('Superuser functions')).toBeNull() +}) + diff --git a/ui-admin/src/navbar/AdminSidebar.tsx b/ui-admin/src/navbar/AdminSidebar.tsx new file mode 100644 index 0000000000..a017bd7c9d --- /dev/null +++ b/ui-admin/src/navbar/AdminSidebar.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react' +import { useUser } from '../user/UserProvider' +import { Link, NavLink, useParams } from 'react-router-dom' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons' +import { Study } from '@juniper/ui-core' +import { studyShortcodeFromPath } from '../study/StudyRouter' +import { useNavContext } from './NavContextProvider' +import { StudySidebar } from './StudySidebar' +import CollapsableMenu from './CollapsableMenu' + +/** renders the left navbar of admin tool */ +const AdminSidebar = () => { + const [open, setOpen] = useState(true) + const { user } = useUser() + const params = useParams() + + const { portalList } = useNavContext() + const studyShortcode = studyShortcodeFromPath(params['*']) + const portalShortcode = params.portalShortcode + + let studyList: Study[] = [] + if (portalList.length) { + studyList = portalList.flatMap(portal => portal.portalStudies.map(ps => ps.study)) + } + + if (user.isAnonymous) { + return
+ } + const currentStudy = studyList.find(study => study.shortcode === studyShortcode) + + return
+ + {!open && } + {open && <> +
+ Juniper + +
+ { currentStudy && } + + {user.superuser && +
  • + All users +
  • + }/>} + } +
    +} + +export default AdminSidebar diff --git a/ui-admin/src/navbar/BaseSidebar.tsx b/ui-admin/src/navbar/BaseSidebar.tsx deleted file mode 100644 index 77a3fd4942..0000000000 --- a/ui-admin/src/navbar/BaseSidebar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' -import { useUser } from '../user/UserProvider' -import { SidebarNavLink } from './AdminNavbar' - -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -const BaseSidebar = () => { - const { user } = useUser() - return
      - {user.superuser &&
    • - All users -
    • } -
    -} - -export default BaseSidebar diff --git a/ui-admin/src/navbar/CollapsableMenu.tsx b/ui-admin/src/navbar/CollapsableMenu.tsx new file mode 100644 index 0000000000..8a7839f440 --- /dev/null +++ b/ui-admin/src/navbar/CollapsableMenu.tsx @@ -0,0 +1,29 @@ +import React, { useId } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' + +/** collapsable thing -- uses bootstrap collapse styling */ +export default function CollapsableMenu({ header, content }: {header: React.ReactNode, content: React.ReactNode}) { + const contentId = useId() + const targetSelector = `#${contentId}` + return
    +
    + +
    +
    + {content} +
    +
    +} diff --git a/ui-admin/src/navbar/NavContextProvider.tsx b/ui-admin/src/navbar/NavContextProvider.tsx new file mode 100644 index 0000000000..5296a8a5c1 --- /dev/null +++ b/ui-admin/src/navbar/NavContextProvider.tsx @@ -0,0 +1,55 @@ +import React, { useContext, useEffect, useState } from 'react' +import { Portal } from '@juniper/ui-core' +import { emptyContextAlertFunction } from '../util/contextUtils' +import Api from '../api/api' +import { studyParticipantsPath } from '../portal/PortalRouter' +import { useNavigate } from 'react-router-dom' +import LoadingSpinner from '../util/LoadingSpinner' + +export type NavContextT = { + breadCrumbs: React.ReactNode[], + setBreadCrumbs: React.Dispatch> + portalList: Portal[] + setPortalList: (portalList: Portal[]) => void +} + +const NavContext = React.createContext({ + breadCrumbs: [], + setBreadCrumbs: emptyContextAlertFunction, + portalList: [], + setPortalList: emptyContextAlertFunction +}) + +/** wrapper function for using the nav context */ +export const useNavContext = () => useContext(NavContext) + +/** Provider for a general navigation -- current study and portal. this blocks rendering of children + * until the list of portals the user has access to is loaded. Accordingly, this should only be used + * inside protected routes, since it requires the user to already be logged in. */ +export default function NavContextProvider({ children }: { children: React.ReactNode}) { + const [breadCrumbs, setBreadCrumbs] = useState([]) + const [portalList, setPortalList] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const navigate = useNavigate() + + const navState: NavContextT = { + breadCrumbs, setBreadCrumbs, portalList, setPortalList + } + useEffect(() => { + Api.getPortals().then(result => { + setPortalList(result) + /** if there's only one study, and the user is going to the homepage, direct to that study's participant page */ + if (result.length === 1 && result[0].portalStudies.length === 1 && window.location.pathname.length < 2) { + const studyShortcode = result[0].portalStudies[0].study.shortcode + navigate(studyParticipantsPath(result[0].shortcode, studyShortcode, 'live'), { replace: true }) + } + setIsLoading(false) + }) + }, []) + return + { isLoading && } + { !isLoading && children } + +} + + diff --git a/ui-admin/src/navbar/NavbarProvider.tsx b/ui-admin/src/navbar/NavbarProvider.tsx deleted file mode 100644 index a88789a108..0000000000 --- a/ui-admin/src/navbar/NavbarProvider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { Dispatch, SetStateAction, useState } from 'react' -import BaseSidebar from './BaseSidebar' - -export type NavbarContextT = { - breadCrumbs: React.ReactNode[], - sidebarContent: React.ReactNode[] | null, - showSidebar: boolean, - setShowSidebar: Dispatch>, - setSidebarContent: Dispatch>, - setBreadCrumbs: Dispatch> -} - -export const emptyNavbarContext: NavbarContextT = { - breadCrumbs: [], - sidebarContent: null, - showSidebar: false, - setShowSidebar: () => alert('error - navbar not initialized'), - setSidebarContent: () => alert('error - navbar not initialized'), - setBreadCrumbs: () => alert('error - navbar not initialized') -} - -export const NavbarContext = React.createContext(emptyNavbarContext) - -/** Provider for a navbar context (does not actually render the navbar) */ -export default function NavbarProvider({ children }: { children: React.ReactNode}) { - const [breadCrumbs, setBreadCrumbs] = useState([]) - const [showSidebar, setShowSidebar] = useState(false) - const [sidebarContent, setSidebarContent] = - useState([]) - - - const navState: NavbarContextT = { - breadCrumbs, setBreadCrumbs, sidebarContent, setSidebarContent, showSidebar, setShowSidebar - } - - return - { children } - -} diff --git a/ui-admin/src/navbar/StudySelector.tsx b/ui-admin/src/navbar/StudySelector.tsx new file mode 100644 index 0000000000..2246f5731d --- /dev/null +++ b/ui-admin/src/navbar/StudySelector.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import Select from 'react-select' +import { Portal } from '@juniper/ui-core' +import { getImageUrl } from 'api/api' + +/** selects a given study and containing portal */ +export default function StudySelector({ portalList, selectedShortcode, setSelectedStudy }: + {portalList: Portal[], selectedShortcode: string, + setSelectedStudy: (portalShortcode: string, studyShortcode: string) => void}) { + /** the same study may appear in multiple portals, so we need to track the associated shortcode */ + const options = portalList + .flatMap(portal => portal.portalStudies.map(ps => + ({ label: ps.study.name, value: ps.study.shortcode, portalCode: portal.shortcode }))) + const selectedOpt = options + .find(opt => opt.value === selectedShortcode) + return updateConfig('passwordProtected', e.target.checked)}/> - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - - -
    + return
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    } export default PortalEnvConfigView diff --git a/ui-admin/src/portal/PortalEnvView.tsx b/ui-admin/src/portal/PortalEnvView.tsx index f36deda9c2..21052d77a5 100644 --- a/ui-admin/src/portal/PortalEnvView.tsx +++ b/ui-admin/src/portal/PortalEnvView.tsx @@ -10,11 +10,9 @@ import { studyKitsPath, studyParticipantsPath } from './PortalRouter' -import PortalEnvPublishControl from './publish/PortalEnvPublishControl' import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs' import { faClipboardCheck } from '@fortawesome/free-solid-svg-icons/faClipboardCheck' import { faUsers } from '@fortawesome/free-solid-svg-icons/faUsers' -import { isSuperuser } from '../user/UserProvider' import { studyEnvMetricsPath } from '../study/StudyEnvironmentRouter' @@ -32,7 +30,6 @@ export default function PortalEnvView({ portal, portalEnv }: return

    {envIcon} {portalEnv.environmentName}

    - { isSuperuser() && }
    @@ -77,10 +74,10 @@ export default function PortalEnvView({ portal, portalEnv }: function StudyConfigView({ portal, study, envName }: {portal: Portal, study: Study, envName: string}) { return
    {study.name} - Content - Participants - Kits - Metrics + Content + Participants + Kits + Metrics
    } diff --git a/ui-admin/src/portal/PortalList.tsx b/ui-admin/src/portal/PortalList.tsx deleted file mode 100644 index f0ef1fbff4..0000000000 --- a/ui-admin/src/portal/PortalList.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import Api, { Portal } from 'api/api' - -import LoadingSpinner from 'util/LoadingSpinner' -import { portalParticipantsPath } from './PortalRouter' - -/** Shows a user the list of portals available to them */ -function PortalList() { - const [portalList, setPortalList] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const navigate = useNavigate() - useEffect(() => { - Api.getPortals().then(result => { - if (result.length === 1) { - navigate(portalParticipantsPath(result[0].shortcode, 'live'), { replace: true }) - } else { - setPortalList(result) - setIsLoading(false) - } - }) - }, []) - return
    -
    -

    Juniper

    - -
    Select a portal
    - -
      - { portalList.map((portal, index) =>
    • - {portal.name} -
    • )} -
    - -
    -
    -
    -} - -export default PortalList diff --git a/ui-admin/src/portal/PortalProvider.tsx b/ui-admin/src/portal/PortalProvider.tsx index b95d1dc50c..922b261dd9 100644 --- a/ui-admin/src/portal/PortalProvider.tsx +++ b/ui-admin/src/portal/PortalProvider.tsx @@ -1,9 +1,9 @@ import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import Api, { Portal } from 'api/api' import LoadingSpinner from 'util/LoadingSpinner' -import { NavBreadcrumb } from 'navbar/AdminNavbar' import AdminUserProvider from '../providers/AdminUserProvider' +import { emptyContextAlertFunction } from '../util/contextUtils' export type PortalContextT = { @@ -26,8 +26,8 @@ export type PortalParams = { } export const PortalContext = React.createContext({ - updatePortal: () => alert('error - portal not yet loaded'), - reloadPortal: () => Promise.resolve(null), + updatePortal: emptyContextAlertFunction, + reloadPortal: emptyContextAlertFunction, portal: null, isLoading: true, isError: false @@ -60,6 +60,7 @@ function RawPortalProvider({ shortcode, children }: /** grabs the latest from the server and updates the portal object */ function reloadPortal(shortcode: string): Promise { + setIsLoading(true) return Api.getPortal(shortcode).then(result => { setPortalState(result) setIsError(false) @@ -98,10 +99,6 @@ function RawPortalProvider({ shortcode, children }: } return - - - {portalState?.name} - {children} diff --git a/ui-admin/src/portal/PortalRouter.tsx b/ui-admin/src/portal/PortalRouter.tsx index a3cf5eca10..1bfcf99074 100644 --- a/ui-admin/src/portal/PortalRouter.tsx +++ b/ui-admin/src/portal/PortalRouter.tsx @@ -1,25 +1,19 @@ import React, { useContext } from 'react' -import { Link, Route, Routes, useParams } from 'react-router-dom' +import { Route, Routes, useParams } from 'react-router-dom' import StudyRouter from '../study/StudyRouter' import PortalDashboard from './PortalDashboard' import { LoadedPortalContextT, PortalContext, PortalParams } from './PortalProvider' import MailingListView from './MailingListView' -import { NavBreadcrumb, SidebarContent } from '../navbar/AdminNavbar' import PortalEnvView from './PortalEnvView' import SiteContentView from './siteContent/SiteContentView' import PortalEnvConfigView from './PortalEnvConfigView' -import PortalSidebar from './PortalSidebar' import PortalUserList from '../user/PortalUserList' import PortalParticipantsView from './PortalParticipantView' -import PortalEnvDiffProvider from './publish/PortalEnvDiffProvider' /** controls routes for within a portal */ export default function PortalRouter() { const portalContext = useContext(PortalContext) as LoadedPortalContextT return <> - - - }/> @@ -36,25 +30,18 @@ export default function PortalRouter() { function PortalEnvRouter({ portalContext }: {portalContext: LoadedPortalContextT}) { const params = useParams() const portalEnvName: string | undefined = params.portalEnv - const { portal, updatePortal } = portalContext + const { portal } = portalContext const portalEnv = portal.portalEnvironments.find(env => env.environmentName === portalEnvName) if (!portalEnv) { return
    No environment matches {portalEnvName}
    } return <> - - - {portalEnvName} - - }/> }/> }/> - }/> }/> }/> @@ -77,42 +64,32 @@ export const mailingListPath = (portalShortcode: string, envName: string) => { return `/${portalShortcode}/env/${envName}/mailingList` } -/** absolute path for the environment diff page */ -export const portalEnvDiffPath = (portalShortcode: string, destEnvName: string, sourceEnvName: string) => { - return `/${portalShortcode}/env/${destEnvName}/diff/${sourceEnvName}` -} - -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc +/** path to edit the site content */ export const siteContentPath = (portalShortcode: string, envName: string) => { return `/${portalShortcode}/env/${envName}/siteContent` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc +/** path to env config for the portal */ export const portalConfigPath = (portalShortcode: string, envName: string) => { return `/${portalShortcode}/env/${envName}/config` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const studyParticipantsPath = (portalShortcode: string, envName: string, studyShortcode: string) => { +/** path to study participant list */ +export const studyParticipantsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}/participants` } /** Construct a path to a study's kit management interface. */ -export const studyKitsPath = (portalShortcode: string, envName: string, studyShortcode: string) => { +export const studyKitsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}/kits` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const studyContentPath = (portalShortcode: string, envName: string, studyShortcode: string) => { +/** view study content, surveys, consents, etc... */ +export const studyContentPath = (portalShortcode: string, studyShortcode: string, envName: string) => { return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc +/** list all participants in all studies for the portal */ export const portalParticipantsPath = (portalShortcode: string, envName: string) => { return `/${portalShortcode}/env/${envName}/participants` } diff --git a/ui-admin/src/portal/PortalSidebar.tsx b/ui-admin/src/portal/PortalSidebar.tsx deleted file mode 100644 index f2d9641693..0000000000 --- a/ui-admin/src/portal/PortalSidebar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { usersPath } from './PortalRouter' -import { SidebarNavLink } from '../navbar/AdminNavbar' - -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -const PortalSidebar = ({ portalShortcode }: {portalShortcode: string}) => { - return
      -
    • - {portalShortcode} users -
    • -
    -} - -export default PortalSidebar diff --git a/ui-admin/src/portal/publish/PortalEnvDiff.test.tsx b/ui-admin/src/portal/publish/PortalEnvDiff.test.tsx index ada555e44c..8eb85a76bc 100644 --- a/ui-admin/src/portal/publish/PortalEnvDiff.test.tsx +++ b/ui-admin/src/portal/publish/PortalEnvDiff.test.tsx @@ -14,9 +14,9 @@ describe('PortalEnvDiff', () => { const { RoutedComponent } = setupRouterTest( 1} - sourceName="sourceEnv" + sourceEnvName="sourceEnv" changeSet={emptyChangeSet}/>) render(RoutedComponent) expect(screen.queryAllByText('no changes')).toHaveLength(4) @@ -34,9 +34,9 @@ describe('PortalEnvDiff', () => { const spyApplyChanges = jest.fn(() => 1) const { RoutedComponent } = setupRouterTest() render(RoutedComponent) expect(screen.queryAllByText('no changes')).toHaveLength(3) diff --git a/ui-admin/src/portal/publish/PortalEnvDiffProvider.tsx b/ui-admin/src/portal/publish/PortalEnvDiffProvider.tsx index d1c122abb2..21d2d7107b 100644 --- a/ui-admin/src/portal/publish/PortalEnvDiffProvider.tsx +++ b/ui-admin/src/portal/publish/PortalEnvDiffProvider.tsx @@ -6,54 +6,60 @@ import { failureNotification, successNotification } from 'util/notifications' import _cloneDeep from 'lodash/cloneDeep' import LoadingSpinner from 'util/LoadingSpinner' import PortalEnvDiffView from './PortalEnvDiffView' -import { Portal, PortalEnvironment } from '@juniper/ui-core/build/types/portal' +import { Portal } from '@juniper/ui-core/build/types/portal' +import { studyPublishingPath } from '../../study/StudyRouter' type PortalEnvDiffProviderProps = { portal: Portal, - portalEnv: PortalEnvironment, + studyShortcode: string, updatePortal: (portal: Portal) => void } /** * loads a diff between two environments, based on the passed-in environment and an environment name in a url param * also contains logic for updating an environment with a changeset */ -const PortalEnvDiffProvider = ({ portal, portalEnv, updatePortal }: PortalEnvDiffProviderProps) => { +const PortalEnvDiffProvider = ({ portal, studyShortcode, updatePortal }: PortalEnvDiffProviderProps) => { const params = useParams() - const sourceEnvName: string | undefined = params.sourceEnvName + const sourceEnvName = params.sourceEnvName + const destEnvName = params.destEnvName const [isLoading, setIsLoading] = useState(true) const [diffResult, setDiffResult] = useState() const navigate = useNavigate() const applyChanges = (changeSet: PortalEnvironmentChange) => { - Api.applyEnvChanges(portal.shortcode, portalEnv.environmentName, changeSet).then(result => { - Store.addNotification(successNotification(`${portalEnv.environmentName} environment updated`)) + Api.applyEnvChanges(portal.shortcode, destEnvName!, changeSet).then(result => { + Store.addNotification(successNotification(`${destEnvName} environment updated`)) const updatedPortal = _cloneDeep(portal) const envIndex = updatedPortal.portalEnvironments.findIndex(env => env.environmentName === result.environmentName) updatedPortal.portalEnvironments[envIndex] = result updatePortal(updatedPortal) - navigate(`/${portal.shortcode}`) + navigate(studyPublishingPath(portal.shortcode, studyShortcode)) }).catch(e => { Store.addNotification(failureNotification(`Update failed: ${ e.message}`)) }) } useEffect(() => { - if (!sourceEnvName) { - alert('no source environment specified') + if (!sourceEnvName || !destEnvName) { return } - Api.fetchEnvDiff(portal.shortcode, sourceEnvName, portalEnv.environmentName).then(result => { + Api.fetchEnvDiff(portal.shortcode, sourceEnvName, destEnvName).then(result => { setDiffResult(result) setIsLoading(false) }).catch(e => { alert(e) setIsLoading(false) }) - }, []) + }, [portal.shortcode, studyShortcode, sourceEnvName, destEnvName]) + + if (!sourceEnvName || !destEnvName) { + return
    Source and dest environment must be specified
    + } + return <> {isLoading && } - {(!isLoading && diffResult) && } } diff --git a/ui-admin/src/portal/publish/PortalEnvDiffView.tsx b/ui-admin/src/portal/publish/PortalEnvDiffView.tsx index 80d0c0dbf6..d5335351f4 100644 --- a/ui-admin/src/portal/publish/PortalEnvDiffView.tsx +++ b/ui-admin/src/portal/publish/PortalEnvDiffView.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react' import { ConfigChange, Portal, - PortalEnvironment, PortalEnvironmentChange, StudyEnvironmentChange } from 'api/api' import { Link } from 'react-router-dom' @@ -73,17 +72,17 @@ const getDefaultStudyEnvChanges = (changes: StudyEnvironmentChange) => { type EnvironmentDiffProps = { portal: Portal, - portalEnv: PortalEnvironment, + sourceEnvName: string, applyChanges: (changeSet: PortalEnvironmentChange) => void, changeSet: PortalEnvironmentChange, - sourceName: string + destEnvName: string } /** * loads and displays the differences between two portal environments * */ export default function PortalEnvDiffView( - { changeSet, portal, portalEnv, applyChanges, sourceName }: EnvironmentDiffProps) { + { changeSet, portal, destEnvName, applyChanges, sourceEnvName }: EnvironmentDiffProps) { const [selectedChanges, setSelectedChanges] = useState(getDefaultPortalEnvChanges(changeSet)) const updateSelectedStudyEnvChanges = (update: StudyEnvironmentChange) => { @@ -99,9 +98,9 @@ export default function PortalEnvDiffView( return

    - Difference: {sourceName} + Difference: {sourceEnvName} - {portalEnv.environmentName} + {destEnvName}

    Select changes to apply diff --git a/ui-admin/src/portal/publish/PortalEnvPublishControl.test.tsx b/ui-admin/src/portal/publish/PortalEnvPublishControl.test.tsx index 0c160a1321..d92e6ab636 100644 --- a/ui-admin/src/portal/publish/PortalEnvPublishControl.test.tsx +++ b/ui-admin/src/portal/publish/PortalEnvPublishControl.test.tsx @@ -2,9 +2,9 @@ import React from 'react' import { render, screen } from '@testing-library/react' import PortalEnvPublishControl from './PortalEnvPublishControl' -import { Portal, PortalEnvironment } from '../../api/api' -import { setupRouterTest } from '../../test-utils/router-testing-utils' -import { portalEnvDiffPath } from '../PortalRouter' +import { Portal, PortalEnvironment } from 'api/api' +import { setupRouterTest } from 'test-utils/router-testing-utils' +import { studyDiffPath } from '../../study/StudyRouter' test('renders a copy link', () => { const sandboxEnv :PortalEnvironment = { @@ -32,11 +32,13 @@ test('renders a copy link', () => { portalStudies: [], portalEnvironments: [sandboxEnv, irbEnv] } - const { RoutedComponent } = setupRouterTest() + const { RoutedComponent } = setupRouterTest() render(RoutedComponent) const copyLink = screen.getByText('Copy from sandbox') expect(copyLink).toBeInTheDocument() - expect(copyLink).toHaveAttribute('href', portalEnvDiffPath(portal.shortcode, 'irb', 'sandbox')) + expect(copyLink).toHaveAttribute('href', + studyDiffPath(portal.shortcode, 'bar', 'sandbox', 'irb')) // irb link shouldn't exist since irb env isn't initialized expect(screen.queryByText('Copy from irb')).toBeNull() }) diff --git a/ui-admin/src/portal/publish/PortalEnvPublishControl.tsx b/ui-admin/src/portal/publish/PortalEnvPublishControl.tsx index 2a3dc7cf51..2b68cf2415 100644 --- a/ui-admin/src/portal/publish/PortalEnvPublishControl.tsx +++ b/ui-admin/src/portal/publish/PortalEnvPublishControl.tsx @@ -1,8 +1,8 @@ -import { Portal, PortalEnvironment } from 'api/api' +import { Portal } from 'api/api' import React, { useState } from 'react' import Select from 'react-select' -import { portalEnvDiffPath } from '../PortalRouter' import { Link } from 'react-router-dom' +import { studyDiffPath } from 'study/StudyRouter' type SelectOptionType = { label: string, value: string } @@ -13,9 +13,8 @@ const ALLOWED_COPY_FLOWS: Record = { } /** modal allowing a user to copy one environment's configs over to another */ -const PortalEnvPublishControl = ({ destEnv, portal }: {destEnv: PortalEnvironment, portal: Portal}) => { - const destEnvName = destEnv.environmentName - +const PortalEnvPublishControl = ({ destEnvName, portal, studyShortcode }: + {destEnvName: string, portal: Portal, studyShortcode: string}) => { const initializedEnvironmentNames = getInitializedEnvironmentNames(portal) const allowedSourceNames = ALLOWED_COPY_FLOWS[destEnvName].filter((envName: string) => { return initializedEnvironmentNames.includes(envName) @@ -28,7 +27,8 @@ const PortalEnvPublishControl = ({ destEnv, portal }: {destEnv: PortalEnvironmen const currentVal = { label: sourceEnvName, value: sourceEnvName } let envSelector = <> if (allowedSourceNames.length == 1) { - envSelector = + envSelector = Copy from {sourceEnvName} } @@ -38,7 +38,8 @@ const PortalEnvPublishControl = ({ destEnv, portal }: {destEnv: PortalEnvironmen opt.value === envName)} + className="me-2" + styles={{ + control: baseStyles => ({ + ...baseStyles, + minWidth: '9em' + }) + }} + onChange={opt => updateEnv(opt?.value)} + /> - - - - - - }/> - - }/> - Unknown survey page
    }/> -
    - - - }/> - - Unknown consent page
    }/> - - }/> - - }/> - Unknown prereg page
    }/> + + }/> + }/> }/> }/> + }/> }/> + }/> + }/> }/> }/> }/> - }/> + + + }/> + Unknown prereg page}/> + + + + }/> + + }/> + Unknown survey page}/> + + + + }/> + + Unknown consent page}/> + + }/> + Unknown study environment page}/> @@ -85,38 +120,68 @@ function StudyEnvironmentRouter({ study }: {study: Study}) { export default StudyEnvironmentRouter -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc +/** helper for participant list path */ +export const participantListPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}/participants` +} + +/** root study environment path */ export const studyEnvPath = (portalShortcode: string, studyShortcode: string, envName: string) => { return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc +/** surveys, consents, etc.. */ +export const studyEnvFormsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}/forms` +} + +/** helper for path to configure study notifications */ +export const studyEnvNotificationsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/env/${envName}/notificationContent` +} + +/** path for viewing a particular notification config path */ export const notificationConfigPath = (config: NotificationConfig, currentEnvPath: string) => { - return `${currentEnvPath}/notificationConfigs/${config.id}` + return `${currentEnvPath}/notificationContent/configs/${config.id}` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const getExportDataBrowserPath = (currentEnvPath: string) => { - return `${currentEnvPath}/export/dataBrowser` +/** path to the export preview */ +export const studyEnvDataBrowserPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/export/dataBrowser` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const studyEnvMetricsPath = (portalShortcode: string, envName: string, studyShortcode: string) => { +/** helper function for metrics route */ +export const studyEnvMetricsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/metrics` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const getDatasetListViewPath = (currentEnvPath: string) => { - return `${currentEnvPath}/export/dataRepo/datasets` +/** + * helper function for mailing list route -- note the mailing list itself might not be study-specific, + * but the route is set to maintain study context + */ +export const studyEnvMailingListPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/mailingList` +} + +/** + * helper function for mailing list route -- note the site content itself might not be study-specific, + * but the route is set to maintain study context + */ +export const studyEnvSiteContentPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/siteContent` +} + +/** helper path for study settings */ +export const studyEnvSiteSettingsPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/settings` +} + +/** helper for dataset list path */ +export const studyEnvDatasetListViewPath = (portalShortcode: string, studyShortcode: string, envName: string) => { + return `${studyEnvPath(portalShortcode, studyShortcode, envName)}/export/dataRepo/datasets` } -// TODO: Add JSDoc -// eslint-disable-next-line jsdoc/require-jsdoc -export const getDatasetDashboardPath = (datasetName: string, currentEnvPath: string) => { +/** helper for path for particular dataset route */ +export const datasetDashboardPath = (datasetName: string, currentEnvPath: string) => { return `${currentEnvPath}/export/dataRepo/datasets/${datasetName}` } diff --git a/ui-admin/src/study/StudyEnvironmentSidebar.tsx b/ui-admin/src/study/StudyEnvironmentSidebar.tsx deleted file mode 100644 index 2fc3172f12..0000000000 --- a/ui-admin/src/study/StudyEnvironmentSidebar.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import { Study, StudyEnvironment } from 'api/api' -import { studyParticipantsPath } from '../portal/PortalRouter' -import { SidebarNavLink } from 'navbar/AdminNavbar' - -/** Sidebar for navigating around configuration of a study environment */ -function StudyEnvironmentSidebar({ portalShortcode, study, currentEnv, currentEnvPath }: - {portalShortcode: string, study: Study, currentEnv: StudyEnvironment, - currentEnvPath: string}) { - return
    -
    - {study.name} - { currentEnv.environmentName } -
    -
    -
      -
    • - Content -
    • -
    • - - Participants - -
    • -
    -
    -} - - -export default StudyEnvironmentSidebar diff --git a/ui-admin/src/study/StudyRouter.tsx b/ui-admin/src/study/StudyRouter.tsx index 85b9d3a59d..2e8170e33d 100644 --- a/ui-admin/src/study/StudyRouter.tsx +++ b/ui-admin/src/study/StudyRouter.tsx @@ -1,11 +1,13 @@ import React from 'react' -import { Link, Route, Routes, useParams } from 'react-router-dom' +import { Route, Routes, useParams } from 'react-router-dom' import { Study } from 'api/api' import { LoadedPortalContextT } from 'portal/PortalProvider' -import { NavBreadcrumb } from 'navbar/AdminNavbar' import StudyEnvironmentRouter from './StudyEnvironmentRouter' import StudyDashboard from './StudyDashboard' +import PortalUserList from '../user/PortalUserList' +import PortalEnvDiffProvider from '../portal/publish/PortalEnvDiffProvider' +import StudyPublishingView from './publishing/StudyPublishingView' export type StudyContextT = { updateStudy: (study: Study) => void @@ -46,13 +48,38 @@ function StudyRouterFromShortcode({ shortcode, portalContext }: const study = matchedPortalStudy?.study return <> - - - {study?.name} - }/> + }/> + }/> + }/> }/> } + +/** given a url path, extracts the study shortcode from it, or returns undefined if the path doesn't contain one */ +export const studyShortcodeFromPath = (path: string | undefined) => { + const match = path?.match(/studies\/([^/]+)/) + return match ? match[1] : undefined +} + +/** path to portal-specific user list, but keeps study in-context */ +export const studyUsersPath = (portalShortcode: string, studyShortcode: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/users` +} + +/** helper for a publishing route that keeps the study env in context */ +export const studyPublishingPath = (portalShortcode: string, studyShortcode: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/publishing` +} + + +/** path for showing the diff between two study environments */ +export const studyDiffPath = (portalShortcode: string, studyShortcode: string, + srcEnvName: string, destEnvName: string) => { + return `/${portalShortcode}/studies/${studyShortcode}/diff/${srcEnvName}/${destEnvName}` +} diff --git a/ui-admin/src/study/StudySettings.tsx b/ui-admin/src/study/StudySettings.tsx new file mode 100644 index 0000000000..a8a88a8d91 --- /dev/null +++ b/ui-admin/src/study/StudySettings.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faEdit } from '@fortawesome/free-solid-svg-icons' +import { StudyEnvContextT } from './StudyEnvironmentRouter' +import { LoadedPortalContextT } from '../portal/PortalProvider' +import PortalEnvConfigView from '../portal/PortalEnvConfigView' +import { PortalEnvironment } from '@juniper/ui-core' + +/** shows settings for both a study and its containing portal */ +export default function StudySettings({ studyEnvContext, portalContext }: +{studyEnvContext: StudyEnvContextT, portalContext: LoadedPortalContextT}) { + const envConfig = studyEnvContext.currentEnv.studyEnvironmentConfig + const portalEnv = portalContext.portal.portalEnvironments + .find(env => + env.environmentName === studyEnvContext.currentEnv.environmentName) as PortalEnvironment + return
    + +
    +

    Study Configuration

    +
    +
    + { envConfig.acceptingEnrollment ? 'Yes' : 'No'} +
    + { envConfig.passwordProtected ? 'Yes' : 'No'} +
    + { envConfig.password } +
    + +
    +
    +
    +

    Website configuration ({portalContext.portal.name})

    + +
    +
    +} diff --git a/ui-admin/src/study/kits/KitEnrolleeSelection.tsx b/ui-admin/src/study/kits/KitEnrolleeSelection.tsx index accfe23aa0..a07f3b7dda 100644 --- a/ui-admin/src/study/kits/KitEnrolleeSelection.tsx +++ b/ui-admin/src/study/kits/KitEnrolleeSelection.tsx @@ -55,7 +55,7 @@ export default function KitEnrolleeSelection({ studyEnvContext }: { studyEnvCont useEffect(() => { loadEnrollees() - }, []) + }, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) const onSubmit = async (kitType: string) => { const enrolleesSelected = Object.keys(rowSelection) diff --git a/ui-admin/src/study/kits/KitList.tsx b/ui-admin/src/study/kits/KitList.tsx index 895c17ea61..0110fe2763 100644 --- a/ui-admin/src/study/kits/KitList.tsx +++ b/ui-admin/src/study/kits/KitList.tsx @@ -40,7 +40,7 @@ export default function KitList({ studyEnvContext }: { studyEnvContext: StudyEnv useEffect(() => { loadKits() - }, []) + }, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) return diff --git a/ui-admin/src/study/kits/KitsRouter.tsx b/ui-admin/src/study/kits/KitsRouter.tsx index 32d8c17757..2eb120b3b9 100644 --- a/ui-admin/src/study/kits/KitsRouter.tsx +++ b/ui-admin/src/study/kits/KitsRouter.tsx @@ -14,8 +14,8 @@ export default function KitsRouter({ studyEnvContext }: {studyEnvContext: StudyE } = studyEnvContext return <> - - kits + + kits }/> diff --git a/ui-admin/src/study/metrics/MetricGraph.tsx b/ui-admin/src/study/metrics/MetricGraph.tsx index 63c9d5a462..461cb07a53 100644 --- a/ui-admin/src/study/metrics/MetricGraph.tsx +++ b/ui-admin/src/study/metrics/MetricGraph.tsx @@ -30,7 +30,7 @@ export default function MetricGraph({ studyEnvContext, metricInfo }: {studyEnvCo }).catch(e => { Store.addNotification(failureNotification(e.message)) }) - }, [metricInfo.name]) + }, [metricInfo.name, studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) const copyRawData = () => { if (!metricData) { diff --git a/ui-admin/src/study/notifications/NotificationContent.tsx b/ui-admin/src/study/notifications/NotificationContent.tsx new file mode 100644 index 0000000000..5393fb035f --- /dev/null +++ b/ui-admin/src/study/notifications/NotificationContent.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import NotificationConfigTypeDisplay, { deliveryTypeDisplayMap } from './NotifcationConfigTypeDisplay' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus' +import { StudyEnvContextT } from '../StudyEnvironmentRouter' + +/** shows configuration of notifications for a study */ +export default function NotificationContent({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) { + const currentEnv = studyEnvContext.currentEnv + return
    +

    Participant Notifications

    +
    +
      + { currentEnv.notificationConfigs.map(config =>
    • +
      + + + ({deliveryTypeDisplayMap[config.deliveryType]}) + +
      +
    • + ) } +
    + +
    +
    +} diff --git a/ui-admin/src/study/participants/ParticipantsRouter.tsx b/ui-admin/src/study/participants/ParticipantsRouter.tsx index d5cf06d63c..2e7670e555 100644 --- a/ui-admin/src/study/participants/ParticipantsRouter.tsx +++ b/ui-admin/src/study/participants/ParticipantsRouter.tsx @@ -8,8 +8,8 @@ import { NavBreadcrumb } from 'navbar/AdminNavbar' /** routes to list or individual enrollee view as appropriate */ export default function ParticipantsRouter({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) { return <> - - + + participants diff --git a/ui-admin/src/study/participants/datarepo/DatasetDashboard.tsx b/ui-admin/src/study/participants/datarepo/DatasetDashboard.tsx index 9c76dd606c..87a04ed1ae 100644 --- a/ui-admin/src/study/participants/datarepo/DatasetDashboard.tsx +++ b/ui-admin/src/study/participants/datarepo/DatasetDashboard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { getDatasetListViewPath, StudyEnvContextT } from 'study/StudyEnvironmentRouter' +import { StudyEnvContextT, studyEnvDatasetListViewPath } from 'study/StudyEnvironmentRouter' import Api, { DatasetDetails, DatasetJobHistory } from 'api/api' import LoadingSpinner from 'util/LoadingSpinner' import { Store } from 'react-notifications-component' @@ -49,7 +49,6 @@ const columns: ColumnDef[] = [{ // TODO: Add JSDoc // eslint-disable-next-line jsdoc/require-jsdoc const DatasetDashboard = ({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) => { - const { currentEnvPath } = studyEnvContext const [showDeleteDatasetModal, setShowDeleteDatasetModal] = useState(false) const [datasetDetails, setDatasetDetails] = useState(undefined) const [datasetJobHistory, setDatasetJobHistory] = useState([]) @@ -102,10 +101,11 @@ const DatasetDashboard = ({ studyEnvContext }: {studyEnvContext: StudyEnvContext useEffect(() => { loadData() - }, []) + }, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) return

    Terra Data Repo

    - + Back to dataset list { user.superuser && datasetDetails?.status == 'CREATED' && diff --git a/ui-admin/src/study/participants/datarepo/DatasetList.tsx b/ui-admin/src/study/participants/datarepo/DatasetList.tsx index 4445e8dc52..8629811cdb 100644 --- a/ui-admin/src/study/participants/datarepo/DatasetList.tsx +++ b/ui-admin/src/study/participants/datarepo/DatasetList.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { getDatasetDashboardPath, StudyEnvContextT } from 'study/StudyEnvironmentRouter' +import { datasetDashboardPath, StudyEnvContextT } from 'study/StudyEnvironmentRouter' import Api, { DatasetDetails } from 'api/api' import LoadingSpinner from 'util/LoadingSpinner' import { Store } from 'react-notifications-component' @@ -25,7 +25,7 @@ const datasetColumns = (currentEnvPath: string): ColumnDef[] => accessorKey: 'datasetName', cell: info => { return info.row.original.status !== 'DELETING' ? - + {info.getValue() as unknown as string} : {info.row.original.datasetName} } @@ -86,7 +86,7 @@ const DatasetList = ({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) = useEffect(() => { loadData() - }, []) + }, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) return

    Study Environment Datasets

    { user.superuser && diff --git a/ui-admin/src/study/participants/datarepo/DeleteDatasetModal.tsx b/ui-admin/src/study/participants/datarepo/DeleteDatasetModal.tsx index fc3fdb7743..b1b8016283 100644 --- a/ui-admin/src/study/participants/datarepo/DeleteDatasetModal.tsx +++ b/ui-admin/src/study/participants/datarepo/DeleteDatasetModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { getDatasetListViewPath, StudyEnvContextT } from 'study/StudyEnvironmentRouter' +import { StudyEnvContextT, studyEnvDatasetListViewPath } from 'study/StudyEnvironmentRouter' import Modal from 'react-bootstrap/Modal' import LoadingSpinner from 'util/LoadingSpinner' import { useNavigate } from 'react-router-dom' @@ -24,7 +24,8 @@ const DeleteDatasetModal = ({ studyEnvContext, datasetName, show, setShow, loadD studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName, datasetName) if (response.ok) { Store.addNotification(successNotification(`Deletion of dataset ${datasetName} has been initiated`)) - navigate(getDatasetListViewPath(studyEnvContext.currentEnvPath)) + navigate(studyEnvDatasetListViewPath(studyEnvContext.portal.shortcode, studyEnvContext.study.shortcode, + studyEnvContext.currentEnv.environmentName)) } else { Store.addNotification(failureNotification(`${datasetName} deletion failed`)) } diff --git a/ui-admin/src/study/participants/enrolleeView/EnrolleeLoader.tsx b/ui-admin/src/study/participants/enrolleeView/EnrolleeLoader.tsx index 39a12fbd7d..f464f29059 100644 --- a/ui-admin/src/study/participants/enrolleeView/EnrolleeLoader.tsx +++ b/ui-admin/src/study/participants/enrolleeView/EnrolleeLoader.tsx @@ -37,8 +37,8 @@ export default function EnrolleeLoader({ studyEnvContext }: {studyEnvContext: St }, [enrolleeShortcode]) return - - + + {enrollee?.shortcode} diff --git a/ui-admin/src/study/participants/export/ExportDataBrowser.tsx b/ui-admin/src/study/participants/export/ExportDataBrowser.tsx index b361acea50..b5782d51e2 100644 --- a/ui-admin/src/study/participants/export/ExportDataBrowser.tsx +++ b/ui-admin/src/study/participants/export/ExportDataBrowser.tsx @@ -81,7 +81,7 @@ const ExportDataBrowser = ({ studyEnvContext }: {studyEnvContext: StudyEnvContex useEffect(() => { loadData() - }, []) + }, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName]) return

    Data export preview

    diff --git a/ui-admin/src/study/participants/participantList/ParticipantList.tsx b/ui-admin/src/study/participants/participantList/ParticipantList.tsx index 70cea0e6ad..cc0189b2ee 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.tsx @@ -4,11 +4,7 @@ import LoadingSpinner from 'util/LoadingSpinner' import { Store } from 'react-notifications-component' import { failureNotification } from 'util/notifications' import { Link, useSearchParams } from 'react-router-dom' -import { - getDatasetListViewPath, - getExportDataBrowserPath, - StudyEnvContextT, studyEnvMetricsPath -} from '../../StudyEnvironmentRouter' +import { StudyEnvContextT } from '../../StudyEnvironmentRouter' import { ColumnDef, flexRender, @@ -21,10 +17,8 @@ import { import { ColumnVisibilityControl, IndeterminateCheckbox, tableHeader } from 'util/tableUtils' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCheck } from '@fortawesome/free-solid-svg-icons' -import ExportDataControl from '../export/ExportDataControl' import AdHocEmailModal from '../AdHocEmailModal' -import EnrolleeSearchFacets, {} from './facets/EnrolleeSearchFacets' -import { facetValuesFromString, facetValuesToString, SAMPLE_FACETS, FacetValue } +import { facetValuesFromString, SAMPLE_FACETS, FacetValue } from 'api/enrolleeSearch' import { Button } from 'components/forms/Button' import { instantToDefaultString } from 'util/timeUtils' @@ -33,7 +27,6 @@ import { instantToDefaultString } from 'util/timeUtils' function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT}) { const { portal, study, currentEnv, currentEnvPath } = studyEnvContext const [participantList, setParticipantList] = useState([]) - const [showExportModal, setShowExportModal] = useState(false) const [showEmailModal, setShowEmailModal] = useState(false) const [isLoading, setIsLoading] = useState(true) const [sorting, setSorting] = React.useState([]) @@ -43,7 +36,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT 'familyName': false, 'contactEmail': false }) - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const facetValues = facetValuesFromString(searchParams.get('facets') ?? '{}', SAMPLE_FACETS) @@ -112,7 +105,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT meta: { columnType: 'string' } - }], [study.shortcode]) + }], [study.shortcode, currentEnv.environmentName]) const table = useReactTable({ @@ -144,15 +137,9 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT setIsLoading(false) } - const updateFacetValues = (facetValues: FacetValue[]) => { - searchEnrollees(facetValues) - searchParams.set('facets', facetValuesToString(facetValues)) - setSearchParams(searchParams) - } - useEffect(() => { searchEnrollees(facetValues) - }, []) + }, [study.shortcode, currentEnv.environmentName]) const numSelected = Object.keys(rowSelection).length const allowSendEmail = numSelected > 0 @@ -164,26 +151,8 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT

    {study.name} Participants

    -
    -
    - Metrics - | - Export preview - | - - | - Terra Data Repo - -
    -
    -
    -
    -
    -
    +
    diff --git a/ui-admin/src/study/publishing/StudyPublishingView.tsx b/ui-admin/src/study/publishing/StudyPublishingView.tsx new file mode 100644 index 0000000000..42d648cc16 --- /dev/null +++ b/ui-admin/src/study/publishing/StudyPublishingView.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { Portal, PortalEnvironment } from '@juniper/ui-core' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faClipboardCheck } from '@fortawesome/free-solid-svg-icons/faClipboardCheck' +import { faUsers } from '@fortawesome/free-solid-svg-icons/faUsers' +import { faWrench } from '@fortawesome/free-solid-svg-icons' +import { isSuperuser } from 'user/UserProvider' +import PortalEnvPublishControl from 'portal/publish/PortalEnvPublishControl' +import { Link } from 'react-router-dom' +import Api from 'api/api' +import { faExternalLink } from '@fortawesome/free-solid-svg-icons/faExternalLink' +import { useConfig } from 'providers/ConfigProvider' +import { studyEnvSiteContentPath } from '../StudyEnvironmentRouter' + + +const ENV_SORT_ORDER = ['sandbox', 'irb', 'live'] +/** Page an admin user sees immediately after logging in */ +export default function StudyPublishingView({ portal, studyShortcode }: {portal: Portal, studyShortcode: string}) { + const sortedEnvs = portal.portalEnvironments.sort((pa, pb) => + ENV_SORT_ORDER.indexOf(pa.environmentName) - ENV_SORT_ORDER.indexOf(pb.environmentName)) + return
    +
    +
      + { sortedEnvs.map(portalEnv =>
    • + +
    • )} +
    +
    +
    +} + +export const ENVIRONMENT_ICON_MAP: Record = { + sandbox: , + irb: , + live: +} + +/** shows publishing related info and controls for a given environment */ +function StudyEnvPublishView({ portal, portalEnv, studyShortcode }: + {portal: Portal, portalEnv: PortalEnvironment, studyShortcode: string}) { + const envIcon = ENVIRONMENT_ICON_MAP[portalEnv.environmentName] + const zoneConfig = useConfig() + const isInitialized = portalEnv.portalEnvironmentConfig.initialized + return
    +
    +

    {envIcon} {portalEnv.environmentName}

    + + Participant view + +
    + + { isSuperuser() && } +
    + { !isInitialized &&
    Not initialized
    } + { isInitialized &&
    + Website + {portalEnv.siteContent && + {portalEnv.siteContent.stableId} v{portalEnv.siteContent.version} + } +
    } +
    +
    +} + diff --git a/ui-admin/src/util/contextUtils.ts b/ui-admin/src/util/contextUtils.ts new file mode 100644 index 0000000000..df96daa117 --- /dev/null +++ b/ui-admin/src/util/contextUtils.ts @@ -0,0 +1,5 @@ +/** placeholder function for using in contexts that can't be full initialized until the provider is rendered */ +export function emptyContextAlertFunction() { + alert('Error, context used outside of scope') + return Promise.resolve(null) +} diff --git a/ui-admin/src/util/tableUtils.tsx b/ui-admin/src/util/tableUtils.tsx index 43d0a49823..cfd4ffaa94 100644 --- a/ui-admin/src/util/tableUtils.tsx +++ b/ui-admin/src/util/tableUtils.tsx @@ -209,9 +209,9 @@ export function IndeterminateCheckbox({ * */ export function ColumnVisibilityControl({ table }: {table: Table}) { const [show, setShow] = useState(false) - return
    + return
    { show &&