-
Notifications
You must be signed in to change notification settings - Fork 66
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
(feat) Restore side navigation #417
Conversation
Size Change: -353 kB (-22%) 🎉 Total Size: 1.25 MB
ℹ️ View Unchanged
|
Could we get some early design feedback on this, @samuelmale? Screenshots or videos would be ideal. |
2861780
to
b8edaaa
Compare
useEffect(() => { | ||
const scrollablePages = formJson.pages.filter((page) => !page.isSubform).map((page) => page); | ||
pageObserver.updateScrollablePages(scrollablePages); | ||
}, [formJson.pages]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't changing this to a useMemo
be a better option for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the useEffect is ideal for this scenario.
return () => { | ||
pageObserver.removeInactivePage(page.id); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need this if it's already being handled in lines 52-53? or does this apply on initial load?
</div> | ||
); | ||
})} | ||
{sessionMode !== 'view' && <hr className={styles.divider} />} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There might be a use case for embedded-view
mode within the active visits that might necessitate this being updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure of the use case but currently, the sidenav isn't supported in "embedded-view"
interface UseCurrentActivePageProps { | ||
pages: FormPage[]; | ||
defaultPage: string; | ||
activePages: string[]; | ||
evaluatedPagesVisibility: boolean; | ||
} | ||
|
||
interface UseCurrentActivePageResult { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding Use***
for interface naming makes the naming convention confusing. I think we should just change these 2 to CurrentActivePageProps
and CurrentActivePageResult
respectively especially since there not being exported anyways.
return false; | ||
} | ||
|
||
return ['extra-wide', 'ultra-wide'].includes(workspaceSize) && hasMultiplePages; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These modes are starting to get confusing. This implies 4 modes yet the design guidelines as well as workspace only has 3 modes. Does extra-wide
have a different behavour from ultra-wide
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ultra-wide
is basically "full screen". See the screenshots in the PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @ibacher i saw that. Then i guess the issue would be with extra-wide
unless that naming is being referenced else where within the patient chart.
const narrowWorkspaceWidth = 26.25; | ||
const widerWorkspaceWidth = 32.25; | ||
const extraWideWorkspaceWidth = 48.25; | ||
|
||
type WorkspaceSize = 'narrow' | 'wider' | 'extra-wide' | 'ultra-wide'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This indicates that extra-wide
and ultra-wide
might be doing the same thing so maybe we should drop extra-wide
naming
const size = useMemo(() => { | ||
if (containerWidth <= narrowWorkspaceWidth) { | ||
return 'narrow'; | ||
} else if (containerWidth <= widerWorkspaceWidth) { | ||
return 'wider'; | ||
} else if (containerWidth <= extraWideWorkspaceWidth) { | ||
return 'extra-wide'; | ||
} else { | ||
return 'ultra-wide'; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤯 which dimensions will ultra-wide
take up? or will it inherit from the screen size? This still feels like unnecessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work! A few nit-picking comments.
useEffect(() => { | ||
if (isInitialized || !evaluatedPagesVisibility) return; | ||
|
||
const initializePage = () => { | ||
// Try to find and set the default page | ||
const defaultPageObject = pages.find(({ label }) => label === defaultPage); | ||
|
||
if (defaultPageObject && !defaultPageObject.isHidden) { | ||
setCurrentActivePage(defaultPageObject.id); | ||
scrollIntoView(defaultPageObject.id); | ||
} else { | ||
// Fall back to first visible page | ||
const firstVisiblePage = pages.find((page) => !page.isHidden); | ||
if (firstVisiblePage) { | ||
setCurrentActivePage(firstVisiblePage.id); | ||
} | ||
} | ||
}; | ||
|
||
initializePage(); | ||
setIsInitialized(true); | ||
}, [pages, defaultPage, evaluatedPagesVisibility, isInitialized]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why bother with the useEffect()
here at all?:
if (!isInitialized && evaluatedPagesVisibility) {
const defaultPageObject = pages.find(({ label }) => label === defaultPage);
// blah, blah, blah
}
This is actually also a weird case where using a ref
instead of state to track whether or not the hook is initialized is actually a performance improvement because the side effect of calling setIsInitialized()
is actually undesirable (we only want the re-render if setCurrentActivePage()
is called).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree we can get rid of the useEffect()
.
This is actually also a weird case where using a ref instead of state to track whether or not the hook is initialized is actually a performance improvement because the side effect of calling setIsInitialized() is actually undesirable
There is a rationale for managing isInitialized
as state. The logic that handles the Waypoint lockout needs to be triggered after initialization, hence the dependency.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes. When I wrote this, I was hoping to get rid of most of the useEffect()
s, but we definitely need it for the timeout
// 1. Initial render completion | ||
// 2. Scroll position establishment | ||
// 3. Waypoint to complete its initial visibility detection | ||
if (isInitialized) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAICT, initialLockTimeoutRef
is only used locally to this hook, so why not have it as a local?
} | ||
|
||
// Cleanup | ||
return () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return () => { | |
return initialLockTimeoutRef.current ? () => { | |
// snip | |
}) : null; |
// If there's a requested page and it's visible, keep it active | ||
if (requestedPage && activePages.includes(requestedPage)) { | ||
setCurrentActivePage(requestedPage); | ||
setTimeout(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're creating a timeout, we should clear the timeout when the effect returns.
setCurrentActivePage(requestedPage); | ||
setTimeout(() => { | ||
setRequestedPage(null); | ||
}, 100); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why 100ms? Could this just be 0?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This pushes the cleanup or clearing of the requested page to a later point. This ensures that we ignore all intermediate Waypoint events as we scroll to the requested page. So the 100ms delay is necessary.
}, [isInitialized]); | ||
|
||
// Handle active pages updates from viewport visibility | ||
useEffect(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way we can move this logic into the requestPage()
callback itself?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's possible but that may introduce some coupling or confusion because these events are propagated by disparate actions. A page request is made when the user clicks on a link in the sidenav while the other events are coming from Waypoint.
import { BehaviorSubject } from 'rxjs'; | ||
import { type FormPage } from '../../types'; | ||
|
||
class PageObserver { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A couple of points here:
- I don't love that this is a library-wide singleton rather than an object tied to a specific render tree, which is what it feels like it should be. This basically adds the artificial restriction that only one form instance (per instance of form-engine-lib) is active at a time.
- Since we have a
usePageObserver()
hook, wouldn't it be better to confine all the reactivity to that? I'm not clear on the advantages of having a separate class here with a bunch of BehaviorSubjects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch!
{pages | ||
.filter((page) => isPageContentVisible(page)) | ||
.map((page) => { | ||
const isActive = page.id === currentActivePage; | ||
const hasError = pagesWithErrors.includes(page.id); | ||
return ( | ||
<div | ||
className={classNames(styles.tab, { | ||
[styles.activeTab]: isActive && !hasError, | ||
[styles.errorTab]: hasError && !isActive, | ||
[styles.activeErrorTab]: hasError && isActive, | ||
})}> | ||
<button | ||
onClick={(e) => { | ||
e.preventDefault(); | ||
requestPage(page.id); | ||
}}> | ||
<span>{page.label}</span> | ||
</button> | ||
</div> | ||
); | ||
})} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like the type of thing that might be better handled in a useMemo()
hook. At the least, the top-level divs here need a key
property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better yet, we would refactor the div / button combo into its own component (it can be an internal component local to this file).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like the type of thing that might be better handled in a useMemo() hook.
A more ideal approach would be memoizing this once in the form renderer and we expect the page observer to report only visible pages, but there is an issue:
Achieving this is likely to beg for the need of a state update of the entire form object every time a field changes, triggering the form to re-render every time the user interacts with a field. Currently, when a field's state changes, only the field and the flattened fields array state are updated (this doesn't trigger a re-render of the entire form).
But why would this be necessary? This boils down to how we determine the visibility of a page. A page is considered to have visible content if:
- The page itself is not hidden.
- At least one section within the page is visible.
- At least one question within each section is visible.
I think for now, we can deploy keys to the heat-map level elements, leveraging React's reconciliation algorithm and address the bigger issue in a different PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, yeah, the whole need to re-render the form was kind of why I was suggesting local memoization, but I think I see your point: any field update can (potentially) trigger new fields to show and effect page visibility, and there isn't an obvious way to tie memoization to changing page visibility, so let's leave that aside.
src/components/sidebar/sidebar.scss
Outdated
} | ||
|
||
.section { | ||
.tab { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think .tab
is a pretty bad name here. Surely "page" or "pageLink" is better?
src/hooks/useFormWorkspaceSize.ts
Outdated
useLayoutEffect(() => { | ||
const handleResize = () => { | ||
const containerWidth = rootRef.current?.parentElement?.offsetWidth; | ||
containerWidth && setContainerWidth(pxToRem(containerWidth)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, for this to be done correct, we need to always pass the actual root element font size:
const rootFontSize = parseInt(getComputedStyle(document.documentElement).fontSize);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch!
src/hooks/useFormWorkspaceSize.ts
Outdated
handleResize(); | ||
}); | ||
|
||
if (rootRef.current) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't the check here actually be:
if (rootRef.current) { | |
if (rootRef.current?.parentElement) { |
257a987
to
9d8e2cb
Compare
@ibacher kindly review my latest changes so that we can have this go in. |
b238831
to
1c17dc8
Compare
Requirements
Summary
Brings back the form's side navigation with some improvements! The nav shows up when you have multiple pages and enough screen space (extra-wide or ultra-wide workspace).
Under the Hood
Three new hooks make this work:
useFormWorkspaceSize
: Figures out how much space we have to work withusePageObserver
: Keeps track of pages, errors, and what's visibleuseCurrentActivePage
: Handles page navigationKnown Limitations
Few things we might want to add later:
Screenshots
extra-wide workspace:
ultra-wide workspace:
wider workspace:
Related Issue
https://openmrs.atlassian.net/browse/O3-3255
Other