From bb1ee8cd8ae9e2c6930ccab1e3f4139920175dbb Mon Sep 17 00:00:00 2001 From: Connor Barker Date: Mon, 3 Feb 2025 11:27:17 -0500 Subject: [PATCH 1/2] editable sidebars --- ui-admin/src/App.css | 8 + ui-admin/src/navbar/SidebarSection.tsx | 71 ++++++ ui-admin/src/navbar/StudySidebar.tsx | 335 +++++++++++++++++-------- 3 files changed, 312 insertions(+), 102 deletions(-) create mode 100644 ui-admin/src/navbar/SidebarSection.tsx diff --git a/ui-admin/src/App.css b/ui-admin/src/App.css index 1f42b47e8a..e3903e9f9c 100644 --- a/ui-admin/src/App.css +++ b/ui-admin/src/App.css @@ -102,3 +102,11 @@ a { .nav-icon:hover { color: #858D9A; } + +.hover-opacity-50:hover { + opacity: 1.0; +} + +.hover-opacity-50 { + opacity: 0.5; +} diff --git a/ui-admin/src/navbar/SidebarSection.tsx b/ui-admin/src/navbar/SidebarSection.tsx new file mode 100644 index 0000000000..c705ca6591 --- /dev/null +++ b/ui-admin/src/navbar/SidebarSection.tsx @@ -0,0 +1,71 @@ +import { NavLink } from 'react-router-dom' +import React from 'react' +import { sidebarNavLinkClasses } from 'navbar/AdminSidebar' +import CollapsableMenu from 'navbar/CollapsableMenu' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faEye, + faEyeSlash +} from '@fortawesome/free-solid-svg-icons' + +export type SidebarItemT = { + key: string + label: string + link: string +} + +export type SidebarSectionT = { + key: string + label: string + items: SidebarItemT[] +} + +const navStyleFunc = ({ isActive }: { isActive: boolean }) => { + return isActive ? { background: 'rgba(255, 255, 255, 0.3)' } : {} +} + +export const SidebarSection = ({ + section, isEditing, hiddenItems, toggleHiddenItem +}: { + section: SidebarSectionT, isEditing: boolean, hiddenItems: string[], toggleHiddenItem: (key: string) => void +}) => { + const shownItems = isEditing + ? section.items // show all items when editing + : section.items.filter(item => !hiddenItems.includes(item.key)) || [] + + if (shownItems.length === 0) { + return <> + } + + return + {shownItems + .map(item => )} + }/> +} + +export const SidebarItem = ({ + item, isEditing, hiddenItems, toggleHiddenItem +}: { item: SidebarItemT, isEditing: boolean, hiddenItems: string[], toggleHiddenItem: (key: string) => void }) => { + return <> +
  • + {item.label} + {isEditing && <> + + } +
  • + +} diff --git a/ui-admin/src/navbar/StudySidebar.tsx b/ui-admin/src/navbar/StudySidebar.tsx index ecc4f378e8..9475987ea5 100644 --- a/ui-admin/src/navbar/StudySidebar.tsx +++ b/ui-admin/src/navbar/StudySidebar.tsx @@ -2,40 +2,48 @@ import { Portal, Study } from '@juniper/ui-core' -import { - NavLink, - useNavigate -} from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { studyKitsPath, studyParticipantsPath } from 'portal/PortalRouter' import StudySelector from './StudySelector' -import React from 'react' +import React, { useEffect } from 'react' import { - adminTasksPath, studyEnvMailingListPath, studyEnvSiteContentPath, + adminTasksPath, studyEnvDataBrowserPath, studyEnvDatasetListViewPath, studyEnvExportIntegrationsPath, studyEnvFormsPath, studyEnvImportPath, + studyEnvMailingListPath, studyEnvMetricsPath, - studyEnvTriggersPath, studyEnvWorkflowPath, - studyEnvSiteSettingsPath + studyEnvSiteContentPath, + studyEnvSiteSettingsPath, + studyEnvTriggersPath, + studyEnvWorkflowPath } from 'study/StudyEnvironmentRouter' - - -import CollapsableMenu from './CollapsableMenu' import { userHasPermission, useUser } from 'user/UserProvider' +import { studyPublishingPath } from 'study/StudyRouter' +import { portalUsersPath } from 'user/AdminUserRouter' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - studyPublishingPath -} from 'study/StudyRouter' -import { sidebarNavLinkClasses } from './AdminSidebar' -import { portalUsersPath } from '../user/AdminUserRouter' + faCheck, + faPencil +} from '@fortawesome/free-solid-svg-icons' +import { + SidebarSection, + SidebarSectionT +} from 'navbar/SidebarSection' +type SidebarConfig = { + hidden: string[] +} + +type SidebarConfigState = { [key: string]: SidebarConfig } /** shows menu options related to the current study */ export const StudySidebar = ({ study, portalList, portalShortcode }: @@ -44,102 +52,225 @@ export const StudySidebar = ({ study, portalList, portalShortcode }: const user = useUser() const portalId = portalList.find(p => p.shortcode === portalShortcode)?.id + const sidebarConfigState: SidebarConfigState = parseSidebarConfigState(localStorage.getItem('sidebarConfig') || '{}') + + const sidebarConfig: SidebarConfig = sidebarConfigState[study.shortcode] || { hidden: [] } + const [hiddenItems, setHiddenItems] = React.useState(sidebarConfig.hidden) + useEffect(() => { + setHiddenItems(sidebarConfig.hidden) + }, [sidebarConfig]) + + const [isEditing, setIsEditing] = React.useState(false) + + const toggleHiddenItem = (key: string) => { + if (hiddenItems.includes(key)) { + sidebarConfig.hidden = hiddenItems.filter(item => item !== key) + } else { + sidebarConfig.hidden = [...hiddenItems, key] + } + sidebarConfigState[study.shortcode] = sidebarConfig + localStorage.setItem('sidebarConfig', JSON.stringify(sidebarConfigState)) + setHiddenItems(sidebarConfig.hidden) + } + /** updates the selected study -- routes to that study's homepage */ const setSelectedStudy = (portalShortcode: string, studyShortcode: string) => { navigate(studyParticipantsPath(portalShortcode, studyShortcode, 'live')) } - const navStyleFunc = ({ isActive }: { isActive: boolean }) => { - return isActive ? { background: 'rgba(255, 255, 255, 0.3)' } : {} + + const userHasPermissionInPortal = (permission: string) => { + if (!portalId) { + return false + } + return userHasPermission(user.user, portalId, permission) } - const studyParams = { + const sections: SidebarSectionT[] = buildStudySidebarSections(userHasPermissionInPortal, portalShortcode, - studyShortcode: study.shortcode - } + study.shortcode) return
    - -
    - -
  • - Participants -
  • -
  • - Kits -
  • -
  • - Tasks -
  • -
  • - Import Participants -
  • -
  • - Mailing List -
  • - }/> - -
  • - Study Trends -
  • -
  • - Data Export -
  • - { portalId && userHasPermission(user.user, portalId, 'export_integration') &&
  • - Export Integrations -
  • - } - { portalId && userHasPermission(user.user, portalId, 'tdr_export') &&
  • - Terra Data Repo -
  • - } - }/> - -
  • - Website -
  • -
  • - Participant Flow -
  • -
  • - Forms & Surveys -
  • -
  • - Emails & Automation -
  • - }/> - -
  • - Publish Content -
  • -
  • - Site Settings -
  • - }/> - -
  • - Team Members -
  • - }/> +
    +
    + + +
    +
    + {sections.map(section => )}
    } + +const parseSidebarConfigState = (data: string): SidebarConfigState => { + try { + return JSON.parse(data) + } catch (e) { + return {} + } +} + +const buildStudySidebarSections = ( + userHasPermissionInPortal: (permission: string) => boolean, + portalShortcode: string, + studyShortcode: string): SidebarSectionT[] => { + const sections: SidebarSectionT[] = [ + { + key: 'research', + label: 'Research Coordination', + items: [ + { + key: 'participants', + label: 'Participants', + link: studyParticipantsPath(portalShortcode, studyShortcode, 'live') + }, + { + key: 'kits', + label: 'Kits', + link: studyKitsPath(portalShortcode, studyShortcode, 'live') + }, + { + key: 'tasks', + label: 'Tasks', + link: adminTasksPath(portalShortcode, studyShortcode, 'live') + }, + { + key: 'import', + label: 'Import Participants', + link: studyEnvImportPath(portalShortcode, studyShortcode, 'sandbox') + }, + { + key: 'mailingList', + label: 'Mailing List', + link: studyEnvMailingListPath({ + portalShortcode, + studyShortcode, + envName: 'live' + }) + } + ] + } + ] + + const analyticsDataSection: SidebarSectionT = { + key: 'analytics', + label: 'Analytics & Data', + items: [ + { + key: 'metrics', + label: 'Study Trends', + link: studyEnvMetricsPath(portalShortcode, studyShortcode, 'live') + }, + { + key: 'dataBrowser', + label: 'Data Export', + link: studyEnvDataBrowserPath(portalShortcode, studyShortcode, 'live') + } + ] + } + + if (userHasPermissionInPortal('export_integration')) { + analyticsDataSection.items.push({ + key: 'exportIntegrations', + label: 'Export Integrations', + link: studyEnvExportIntegrationsPath({ + portalShortcode, + studyShortcode, + envName: 'live' + }) + }) + } + + if (userHasPermissionInPortal('tdr_export')) { + analyticsDataSection.items.push({ + key: 'terraDataRepo', + label: 'Terra Data Repo', + link: studyEnvDatasetListViewPath(portalShortcode, studyShortcode, 'live') + }) + } + + sections.push(analyticsDataSection, + { + key: 'design', + label: 'Design & Build', + items: [ + { + key: 'siteContent', + label: 'Website', + link: studyEnvSiteContentPath({ + portalShortcode, + studyShortcode, + envName: 'sandbox' + }) + }, + { + key: 'workflow', + label: 'Participant Flow', + link: studyEnvWorkflowPath({ + portalShortcode, + studyShortcode, + envName: 'sandbox' + }) + }, + { + key: 'forms', + label: 'Forms & Surveys', + link: studyEnvFormsPath(portalShortcode, studyShortcode, 'sandbox') + }, + { + key: 'triggers', + label: 'Emails & Automation', + link: studyEnvTriggersPath({ + portalShortcode, + studyShortcode, + envName: 'sandbox' + }) + } + ] + }, + { + key: 'publish', + label: 'Publish', + items: [ + { + key: 'publishContent', + label: 'Publish Content', + link: studyPublishingPath(portalShortcode, studyShortcode) + }, + { + key: 'siteSettings', + label: 'Site Settings', + link: studyEnvSiteSettingsPath(portalShortcode, studyShortcode, 'live') + } + ] + }, + { + key: 'manage', + label: 'Manage', + items: [ + { + key: 'teamMembers', + label: 'Team Members', + link: portalUsersPath({ + portalShortcode, + studyShortcode, + envName: 'live' + }) + } + ] + }) + + return sections +} From 2afa7bcffb3f914a92589f8a8b4dd2770d5d0610 Mon Sep 17 00:00:00 2001 From: Connor Barker Date: Mon, 3 Feb 2025 14:35:18 -0500 Subject: [PATCH 2/2] edit sidebar tests --- ui-admin/src/App.css | 8 ++ ui-admin/src/navbar/AdminSidebar.tsx | 90 +++++++++++++++++++++-- ui-admin/src/navbar/SidebarSection.tsx | 1 + ui-admin/src/navbar/StudySidebar.test.tsx | 87 +++++++++++++++++++++- ui-admin/src/navbar/StudySidebar.tsx | 85 +++++++++------------ 5 files changed, 210 insertions(+), 61 deletions(-) diff --git a/ui-admin/src/App.css b/ui-admin/src/App.css index e3903e9f9c..b949f819ba 100644 --- a/ui-admin/src/App.css +++ b/ui-admin/src/App.css @@ -110,3 +110,11 @@ a { .hover-opacity-50 { opacity: 0.5; } + +.pointer-none { + pointer-events: none; +} + +.pointer-auto { + pointer-events: auto; +} diff --git a/ui-admin/src/navbar/AdminSidebar.tsx b/ui-admin/src/navbar/AdminSidebar.tsx index f0b2237887..dc322f04c2 100644 --- a/ui-admin/src/navbar/AdminSidebar.tsx +++ b/ui-admin/src/navbar/AdminSidebar.tsx @@ -9,7 +9,11 @@ import { useParams } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCaretRight } from '@fortawesome/free-solid-svg-icons' +import { + faCaretRight, + faPencil, + faSave +} from '@fortawesome/free-solid-svg-icons' import { Study } from '@juniper/ui-core' import { studyShortcodeFromPath } from 'study/StudyRouter' import { useNavContext } from './NavContextProvider' @@ -27,6 +31,22 @@ const ZONE_COLORS: { [index: string]: string } = { export const sidebarNavLinkClasses = 'text-white p-1 rounded w-100 d-block sidebar-nav-link' +export type StudySidebarConfig = { + hidden: string[] +} + +export type SidebarConfig = { + studyConfig?: { [studyShortcode: string]: StudySidebarConfig } +} + +const parseSidebarConfigState = (data: string): SidebarConfig => { + try { + return JSON.parse(data) + } catch (e) { + return {} + } +} + /** renders the left navbar of admin tool */ const AdminSidebar = ({ config }: { config: Config }) => { const SHOW_SIDEBAR_KEY = 'adminSidebar.show' @@ -46,6 +66,50 @@ const AdminSidebar = ({ config }: { config: Config }) => { const currentStudy = studyList.find(study => study.shortcode === studyShortcode) const color = ZONE_COLORS[config.deploymentZone] || ZONE_COLORS['prod'] + + const sidebarConfig: SidebarConfig = parseSidebarConfigState(localStorage.getItem('sidebarConfig') || '{}') + + const [isEditingSidebarConfig, setIsEditingSidebarConfig] = React.useState(false) + + const getStudyConfig = (studyShortcode: string): StudySidebarConfig => { + if (!sidebarConfig.studyConfig) { + sidebarConfig.studyConfig = {} + localStorage.setItem('sidebarConfig', JSON.stringify(sidebarConfig)) + } + + if (!sidebarConfig.studyConfig[studyShortcode]) { + sidebarConfig.studyConfig[studyShortcode] = { hidden: [] } + localStorage.setItem('sidebarConfig', JSON.stringify(sidebarConfig)) + } + + + return sidebarConfig.studyConfig[studyShortcode] + } + + const setStudyConfig = (studyShortcode: string, studyConfig: StudySidebarConfig) => { + if (!sidebarConfig.studyConfig) { + sidebarConfig.studyConfig = {} + } + + sidebarConfig.studyConfig[studyShortcode] = studyConfig + localStorage.setItem('sidebarConfig', JSON.stringify(sidebarConfig)) + } + + const toggleHiddenItem = (key: string) => { + if (!currentStudy) { + return + } + + const studyConfig = getStudyConfig(currentStudy.shortcode) + const hiddenItems = studyConfig.hidden + if (hiddenItems.includes(key)) { + studyConfig.hidden = hiddenItems.filter(item => item !== key) + } else { + studyConfig.hidden = [...hiddenItems, key] + } + setStudyConfig(currentStudy.shortcode, studyConfig) + } + // automatically collapse the sidebar for mobile-first routes useEffect(() => { if (isMobileFirstRoute()) { @@ -58,8 +122,8 @@ const AdminSidebar = ({ config }: { config: Config }) => { } return
    - <> + className="p-2 pt-3 d-flex flex-column"> +
    { open && Juniper }
    { open && <> - { currentStudy && } {user?.superuser && { }/>} } - +
    + {currentStudy &&
    + +
    }
    } diff --git a/ui-admin/src/navbar/SidebarSection.tsx b/ui-admin/src/navbar/SidebarSection.tsx index c705ca6591..4ff3738ab7 100644 --- a/ui-admin/src/navbar/SidebarSection.tsx +++ b/ui-admin/src/navbar/SidebarSection.tsx @@ -62,6 +62,7 @@ export const SidebarItem = ({ diff --git a/ui-admin/src/navbar/StudySidebar.test.tsx b/ui-admin/src/navbar/StudySidebar.test.tsx index 07c473b92a..a291febc72 100644 --- a/ui-admin/src/navbar/StudySidebar.test.tsx +++ b/ui-admin/src/navbar/StudySidebar.test.tsx @@ -1,10 +1,20 @@ import React from 'react' -import { mockAdminUser, MockUserProvider } from 'test-utils/user-mocking-utils' -import { render, screen } from '@testing-library/react' +import { + mockAdminUser, + MockUserProvider +} from 'test-utils/user-mocking-utils' +import { + render, + screen +} from '@testing-library/react' import { StudySidebar } from './StudySidebar' -import { mockPortal, mockStudyEnvContext } from '../test-utils/mocking-utils' +import { + mockPortal, + mockStudyEnvContext +} from '../test-utils/mocking-utils' import { setupRouterTest } from '@juniper/ui-core' +import { act } from 'react-dom/test-utils' test('renders the study selector and sub menus', async () => { const { study } = mockStudyEnvContext() @@ -15,11 +25,80 @@ test('renders the study selector and sub menus', async () => { }) const { RoutedComponent } = setupRouterTest( - + { + }} + /> ) render(RoutedComponent) expect(screen.getByText(study.name)).toBeInTheDocument() expect(screen.getByText('Research Coordination')).toBeVisible() expect(screen.getByText('Participants')).toBeVisible() expect(screen.getByText('Study Trends')).toBeVisible() + + expect(screen.queryByText('Toggle visibility for Participants')).not.toBeInTheDocument() +}) + + +test('hides hidden items', async () => { + const { study } = mockStudyEnvContext() + const portal = mockPortal() + portal.portalStudies.push({ + createdAt: 0, + study + }) + const { RoutedComponent } = setupRouterTest( + + { + }} + /> + ) + render(RoutedComponent) + expect(screen.getByText(study.name)).toBeInTheDocument() + expect(screen.getByText('Research Coordination')).toBeVisible() + expect(screen.queryByText('Participants')).not.toBeInTheDocument() + expect(screen.getByText('Study Trends')).toBeVisible() +}) + +test('toggles hidden items', async () => { + const { study } = mockStudyEnvContext() + const portal = mockPortal() + portal.portalStudies.push({ + createdAt: 0, + study + }) + + const toggleHiddenItem = jest.fn() + + const { RoutedComponent } = setupRouterTest( + + + ) + render(RoutedComponent) + expect(screen.getByText(study.name)).toBeInTheDocument() + expect(screen.queryByText('Participants')).toBeVisible() + + expect(toggleHiddenItem).not.toHaveBeenCalled() + + act(() => screen.getByLabelText('Toggle visibility for Participants').click()) + + expect(toggleHiddenItem).toHaveBeenCalledWith('participants') }) diff --git a/ui-admin/src/navbar/StudySidebar.tsx b/ui-admin/src/navbar/StudySidebar.tsx index 9475987ea5..f38d5ae8df 100644 --- a/ui-admin/src/navbar/StudySidebar.tsx +++ b/ui-admin/src/navbar/StudySidebar.tsx @@ -8,7 +8,7 @@ import { studyParticipantsPath } from 'portal/PortalRouter' import StudySelector from './StudySelector' -import React, { useEffect } from 'react' +import React from 'react' import { adminTasksPath, studyEnvDataBrowserPath, @@ -29,48 +29,44 @@ import { } from 'user/UserProvider' import { studyPublishingPath } from 'study/StudyRouter' import { portalUsersPath } from 'user/AdminUserRouter' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { - faCheck, - faPencil -} from '@fortawesome/free-solid-svg-icons' import { SidebarSection, SidebarSectionT } from 'navbar/SidebarSection' - -type SidebarConfig = { - hidden: string[] -} - -type SidebarConfigState = { [key: string]: SidebarConfig } +import { StudySidebarConfig } from 'navbar/AdminSidebar' /** shows menu options related to the current study */ -export const StudySidebar = ({ study, portalList, portalShortcode }: - { study: Study, portalList: Portal[], portalShortcode: string }) => { +export const StudySidebar = ({ + study, + portalList, + portalShortcode, + studySidebarConfig, + isEditingSidebarConfig, + toggleHiddenItem +}: { + study: Study, + portalList: Portal[], + portalShortcode: string, + studySidebarConfig: StudySidebarConfig, + isEditingSidebarConfig: boolean, + toggleHiddenItem: (key: string) => void +}) => { const navigate = useNavigate() const user = useUser() const portalId = portalList.find(p => p.shortcode === portalShortcode)?.id - const sidebarConfigState: SidebarConfigState = parseSidebarConfigState(localStorage.getItem('sidebarConfig') || '{}') - const sidebarConfig: SidebarConfig = sidebarConfigState[study.shortcode] || { hidden: [] } - const [hiddenItems, setHiddenItems] = React.useState(sidebarConfig.hidden) - useEffect(() => { - setHiddenItems(sidebarConfig.hidden) - }, [sidebarConfig]) + const [hiddenItems, setHiddenItems] = React.useState(studySidebarConfig.hidden) + React.useEffect(() => { + setHiddenItems(studySidebarConfig.hidden) + }, [studySidebarConfig]) - const [isEditing, setIsEditing] = React.useState(false) - - const toggleHiddenItem = (key: string) => { + const onToggleHiddenItem = (key: string) => { if (hiddenItems.includes(key)) { - sidebarConfig.hidden = hiddenItems.filter(item => item !== key) + setHiddenItems(hiddenItems.filter(i => i !== key)) } else { - sidebarConfig.hidden = [...hiddenItems, key] + setHiddenItems([...hiddenItems, key]) } - sidebarConfigState[study.shortcode] = sidebarConfig - localStorage.setItem('sidebarConfig', JSON.stringify(sidebarConfigState)) - setHiddenItems(sidebarConfig.hidden) } /** updates the selected study -- routes to that study's homepage */ @@ -90,38 +86,23 @@ export const StudySidebar = ({ study, portalList, portalShortcode }: study.shortcode) return
    -
    -
    - + -
    - -
    {sections.map(section => )} + toggleHiddenItem={key => { + toggleHiddenItem(key) + onToggleHiddenItem(key) + }}/>)}
    } -const parseSidebarConfigState = (data: string): SidebarConfigState => { - try { - return JSON.parse(data) - } catch (e) { - return {} - } -} - const buildStudySidebarSections = ( userHasPermissionInPortal: (permission: string) => boolean, portalShortcode: string,