Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JN-1608][member's choice] editable sidebars #1452

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ui-admin/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,19 @@ a {
.nav-icon:hover {
color: #858D9A;
}

.hover-opacity-50:hover {
opacity: 1.0;
}

.hover-opacity-50 {
opacity: 0.5;
}

.pointer-none {
pointer-events: none;
}

.pointer-auto {
pointer-events: auto;
}
90 changes: 85 additions & 5 deletions ui-admin/src/navbar/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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()) {
Expand All @@ -58,8 +122,8 @@ const AdminSidebar = ({ config }: { config: Config }) => {
}

return <div style={{ backgroundColor: color, minHeight: '100vh', minWidth: open ? '250px' : '50px' }}
className="p-2 pt-3">
<>
className="p-2 pt-3 d-flex flex-column">
<div style={{ minHeight: '100vh', height: '100%' }}>
<div className="d-flex justify-content-between align-items-center">
{ open && <Link to="/" className="text-white fs-4 px-2 rounded-1 sidebar-nav-link flex-grow-1">Juniper</Link> }
<Button variant="secondary" className="m-1 text-light" tooltipPlacement={'right'}
Expand All @@ -73,7 +137,12 @@ const AdminSidebar = ({ config }: { config: Config }) => {
</Button>
</div>
{ open && <>
{ currentStudy && <StudySidebar study={currentStudy} portalList={portalList}
{currentStudy && <StudySidebar
study={currentStudy}
isEditingSidebarConfig={isEditingSidebarConfig}
toggleHiddenItem={toggleHiddenItem}
studySidebarConfig={getStudyConfig(currentStudy.shortcode)}
portalList={portalList}
portalShortcode={portalShortcode!}/> }

{user?.superuser && <CollapsableMenu header={'Superuser Functions'} content={
Expand All @@ -95,7 +164,18 @@ const AdminSidebar = ({ config }: { config: Config }) => {
</li>
</ul>}/>}
</>}
</>
</div>
{currentStudy && <div className="sticky-bottom w-100 d-flex justify-content-end p-1 pointer-none">
<button
className="btn btn-secondary btn-sm text-white hover-opacity-50 pointer-auto"
onClick={() => setIsEditingSidebarConfig(!isEditingSidebarConfig)}
aria-label={isEditingSidebarConfig ? 'Save sidebar config' : 'Edit sidebar config'}
>
{isEditingSidebarConfig
? <FontAwesomeIcon icon={faSave}/>
: <FontAwesomeIcon icon={faPencil}/>}
</button>
</div>}
</div>
}

Expand Down
72 changes: 72 additions & 0 deletions ui-admin/src/navbar/SidebarSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 <CollapsableMenu header={section.label} content={<ul className="list-unstyled">
{shownItems
.map(item => <SidebarItem
key={item.key}
item={item}
isEditing={isEditing}
hiddenItems={hiddenItems}
toggleHiddenItem={toggleHiddenItem}
/>)}
</ul>}/>
}

export const SidebarItem = ({
item, isEditing, hiddenItems, toggleHiddenItem
}: { item: SidebarItemT, isEditing: boolean, hiddenItems: string[], toggleHiddenItem: (key: string) => void }) => {
return <>
<li className="mb-2 d-flex">
<NavLink
to={item.link}
className={sidebarNavLinkClasses}
style={navStyleFunc}>{item.label}</NavLink>
{isEditing && <>
<button
className="btn btn-secondary btn-sm text-white hover-opacity-50"
onClick={() => toggleHiddenItem(item.key)}
aria-label={`Toggle visibility for ${item.label}`}
>
{hiddenItems.includes(item.key) ? <FontAwesomeIcon icon={faEyeSlash} /> : <FontAwesomeIcon icon={faEye}/>}
</button>
</>}
</li>
</>
}
87 changes: 83 additions & 4 deletions ui-admin/src/navbar/StudySidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -15,11 +25,80 @@ test('renders the study selector and sub menus', async () => {
})
const { RoutedComponent } = setupRouterTest(
<MockUserProvider user={mockAdminUser(true)}>
<StudySidebar study={study} portalList={[portal]} portalShortcode={portal.shortcode}/>
<StudySidebar
study={study}
portalList={[portal]}
portalShortcode={portal.shortcode}
studySidebarConfig={{ hidden: [] }}
isEditingSidebarConfig={false}
toggleHiddenItem={() => {
}}
/>
</MockUserProvider>)
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(
<MockUserProvider user={mockAdminUser(true)}>
<StudySidebar
study={study}
portalList={[portal]}
portalShortcode={portal.shortcode}
studySidebarConfig={{ hidden: ['participants'] }}
isEditingSidebarConfig={false}
toggleHiddenItem={() => {
}}
/>
</MockUserProvider>)
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(
<MockUserProvider user={mockAdminUser(true)}>
<StudySidebar
study={study}
portalList={[portal]}
portalShortcode={portal.shortcode}
studySidebarConfig={{ hidden: [] }}
isEditingSidebarConfig={true}
toggleHiddenItem={toggleHiddenItem}
/>
</MockUserProvider>)
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')
})
Loading
Loading