Skip to content

Commit

Permalink
Significantly improved DOM masking. useLocalStorage can now sync acro…
Browse files Browse the repository at this point in the history
…ss separate helx tabs
  • Loading branch information
frostyfan109 committed Feb 27, 2024
1 parent 3d2b8d9 commit dac48ed
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 97 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/gatsbyjs__reach-router": "^2.0.4",
"@types/node": "^18.7.8",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
Expand Down
9 changes: 6 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useTourContext
} from './contexts'
import { Layout } from './components/layout'
import { HelxSearch } from './components/search'
import { NotFoundView } from './views'

const ContextProviders = ({ children }) => {
Expand All @@ -18,9 +19,11 @@ const ContextProviders = ({ children }) => {
<AnalyticsProvider>
<ActivityProvider>
<InstanceProvider>
<TourProvider>
{children}
</TourProvider>
<HelxSearch>
<TourProvider>
{children}
</TourProvider>
</HelxSearch>
</InstanceProvider>
</ActivityProvider>
</AnalyticsProvider>
Expand Down
4 changes: 3 additions & 1 deletion src/components/layout/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Fragment, useState } from 'react'
import { Layout as AntLayout, Button, Menu, Grid, Divider } from 'antd'
import { LinkOutlined } from '@ant-design/icons'
import { useLocation, useNavigate, Link } from '@gatsbyjs/reach-router'
import { useEnvironment, useAnalytics, useWorkspacesAPI } from '../../contexts';
import { useEnvironment, useAnalytics, useWorkspacesAPI, useTourContext } from '../../contexts';
import { MobileMenu } from './menu';
import { SidePanel } from '../side-panel/side-panel';
import './layout.css';
Expand All @@ -13,6 +13,7 @@ const { useBreakpoint } = Grid
export const Layout = ({ children }) => {
const { helxAppstoreUrl, routes, context, basePath } = useEnvironment()
const { api, loading: apiLoading, loggedIn, appstoreContext } = useWorkspacesAPI()
const { tour } = useTourContext()
const { analyticsEvents } = useAnalytics()
const { md } = useBreakpoint()
const baseLinkPath = context.workspaces_enabled === 'true' ? '/helx' : ''
Expand Down Expand Up @@ -86,6 +87,7 @@ export const Layout = ({ children }) => {
</Button>
</div>
)}
<div style={{ height: "100%" }}><Button ghost type="primary" onClick={ () => tour.start() }>Tour</Button></div>
</div>
) : (
<MobileMenu menu={routes} />
Expand Down
113 changes: 82 additions & 31 deletions src/contexts/tour-context/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import { Fragment, ReactNode, createContext, useContext, useEffect, useMemo, use
import { renderToStaticMarkup } from 'react-dom/server'
import { useShepherdTour, Tour, ShepherdOptionsWithType } from 'react-shepherd'
import { useEnvironment } from '../environment-context'
import { SearchLayout } from '../../components/search'
import { SearchLayout, useHelxSearch } from '../../components/search'
import { SearchView } from '../../views'
import { useSyntheticDOMMask } from '../../hooks'
import 'shepherd.js/dist/css/shepherd.css'
const { useLocation, useNavigate } = require('@gatsbyjs/reach-router')

interface ShepherdOptionsWithTypeFixed extends ShepherdOptionsWithType {
when?: any
}

export interface ITourContext {
tour: any
Expand All @@ -29,90 +35,135 @@ function setNativeValue(element: any, value: any) {
}

export const TourProvider = ({ children }: ITourProvider ) => {
const { context } = useEnvironment() as any
const { context, routes, basePath} = useEnvironment() as any
const { layout } = useHelxSearch() as any
const location = useLocation()
const navigate = useNavigate()

const removeTrailingSlash = (url: string) => url.endsWith("/") ? url.slice(0, url.length - 1) : url
const activeRoutes = useMemo<any[] | undefined>(() => {
if (basePath === undefined) return undefined
return routes.filter((route: any) => (
removeTrailingSlash(`${removeTrailingSlash(basePath)}${route.path}`) === removeTrailingSlash(location.pathname)
)).flatMap((route: any) => ([
route,
...routes.filter((m: any) => m.path === route.parent)
])).map((route: any) => route.path)
}, [basePath, routes])
const isSearchActive = useMemo(() => activeRoutes?.some((route) => route.component instanceof SearchView), [activeRoutes])

// const searchBarDomMask = useSyntheticDOMMask(".search-bar, .search-button, .search-autocomplete-suggestions")
const searchBarDomMask = useSyntheticDOMMask(".search-bar, .search-button")

const tourOptions = useMemo<Tour.TourOptions>(() => ({
defaultStepOptions: {
cancelIcon: {
enabled: true
}
},
scrollTo: true,
canClickTarget: true,
classes: "",
highlightClass: "tour-highlighted",
buttons: [
{
classes: 'shepherd-button-primary',
text: 'Back',
type: 'back'
},
{
classes: 'shepherd-button-primary',
text: 'Next',
type: 'next'
}
]
},
useModalOverlay: true
}), []);
}), [])

const tourSteps = useMemo<ShepherdOptionsWithType[]>(() => ([
const tourSteps = useMemo<(ShepherdOptionsWithTypeFixed)[]>(() => ([
{
id: 'intro',
id: 'search.intro',
attachTo: {
element: ".search-bar",
element: searchBarDomMask.selector!,
on: 'bottom'
},
beforeShowPromise: async () => {},
beforeShowPromise: async () => {
await navigate(basePath)
const input = document.querySelector(".search-bar input") as HTMLInputElement
if (input) input.focus()
},
buttons: [
{
classes: 'shepherd-button-secondary',
text: 'Exit',
type: 'cancel'
},
// {
// classes: 'shepherd-button-primary',
// text: 'Back',
// type: 'back'
// },
{
classes: 'shepherd-button-primary',
text: 'Next',
type: 'next'
}
],
classes: 'custom-1',
highlightClass: 'highlight',
scrollTo: false,
cancelIcon: {
enabled: true,
},
canClickTarget: true,
title: `Welcome to ${ context.meta.title }`,
text: renderToStaticMarkup(
<div>
You can search for biomedical concepts, studies, and variables here.<br /><br />
Try typing something and press enter.
Try typing something and press enter or click search.
</div>
),
when: {
show: () => { searchBarDomMask.showMask() },
hide: () => { searchBarDomMask.hideMask() },
cancel: () => { searchBarDomMask.hideMask() },
complete: () => { searchBarDomMask.hideMask() }
}
},
{
id: 'search.concept.intro',
attachTo: {
element: ".result-card",
on: 'right'
},
beforeShowPromise: async () => {},
scrollTo: false,
modalOverlayOpeningPadding: 16,
title: `step 2`,
text: renderToStaticMarkup(
<div>
step 2
</div>
),
when: {
show: () => {},
hide: () => {},
cancel: () => {},
complete: () => {}
show: () => { searchBarDomMask.showMask() },
// hide: () => { searchBarDomMask.hideMask() },
// cancel: () => { searchBarDomMask.hideMask() },
// complete: () => { searchBarDomMask.hideMask() }
}
}
]), [])
]), [isSearchActive, searchBarDomMask, basePath, navigate])

const tour = useShepherdTour({ tourOptions, steps: tourSteps })

useEffect(() => {
let existingSettings = new Map<string, string | null>()
// Some default UI behaviors are assumed for the tour (e.g. search will bring you to the concept view first)
const override = (name: string, newValue: any) => {
console.log("overriding setting", name)
// console.info("overriding setting", name)
existingSettings.set(name, localStorage.getItem(name))
localStorage.setItem(name, JSON.stringify(newValue))
}
const restore = (name: string) => {
console.log("restoring", name)
// console.info("restoring setting", name)
const restoredValue = existingSettings.get(name)!
if (restoredValue === null) localStorage.removeItem(name)
else localStorage.setItem(name, restoredValue)
}
const overrideSettings = () => {
console.log("overriding")
// console.log("overriding")
override("search_history", [])
override("search_layout", SearchLayout.GRID)
}
const restoreSettings = () => {
console.log("restoring", Array.from(existingSettings.keys()).length, "settings")
// console.log("restoring", Array.from(existingSettings.keys()).length, "settings")
Array.from(existingSettings.keys()).forEach((overridedSetting) => {
restore(overridedSetting)
})
Expand Down
25 changes: 19 additions & 6 deletions src/hooks/use-local-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ if (!window.hasOwnProperty("localStorage2")) {
}
}
window.localStorage.setItem = function() {
console.log("value:", arguments[1])
const cancelled = window.dispatchEvent(new CustomEvent("localStorageModified", {
// If arguments undefined,
detail: { type: "set", key: arguments[0], value: arguments[1] },
Expand All @@ -43,7 +42,14 @@ window.localStorage.clear = function() {
!cancelled && window.localStorage2.clear.apply(this, arguments)
}

export const useLocalStorage = (key, initialValue) => {
/**
*
* @param {string} key - Name of the key in localStorage to use.
* @param {any} initialValue - Default value (if key is not set), may be any JSON-serializable value
* @param {boolean} [observeOtherPages=false] - If true, watches for external localStorage changes to the value in other tabs/windows.
* @returns
*/
export const useLocalStorage = (key, initialValue, observeOtherPages=true) => {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
Expand Down Expand Up @@ -78,6 +84,10 @@ export const useLocalStorage = (key, initialValue) => {

useEffect(() => {
const storageCallback = (e) => {
const newValue = JSON.parse(localStorage.getItem(key))
setStoredValue(newValue)
}
const modifiedCallback = (e) => {
const { type } = e.detail
let newValue
switch (type) {
Expand All @@ -98,15 +108,18 @@ export const useLocalStorage = (key, initialValue) => {
break
}
}
console.log("storage callback new value", key, newValue)
// Don't double set state, could lead to weird race conditions with other code working with localStorage.
setStoredValue(newValue)
}
window.addEventListener("localStorageModified", storageCallback)
// Storage changed in another tab/window
if (observeOtherPages) window.addEventListener("storage", storageCallback)
// Storage changed in this tab
window.addEventListener("localStorageModified", modifiedCallback)
return () => {
window.removeEventListener("localStorageModified", storageCallback)
window.removeEventListener("storage", storageCallback)
window.removeEventListener("localStorageModified", modifiedCallback)
}
}, [key])
}, [key, observeOtherPages])

return [storedValue, setValue]
}
Loading

0 comments on commit dac48ed

Please sign in to comment.