From 740d4c723c88ce14fb9e4c063f604dfc16f3f27f Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Thu, 25 Jan 2024 19:42:00 -0500 Subject: [PATCH 01/71] Add precanned order for data source tags in variable view. Only added heal sources. --- .../variable-view-layout/variable-results.js | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/components/search/results/variable-view-layout/variable-results.js b/src/components/search/results/variable-view-layout/variable-results.js index 3faaf236..c8154cf0 100644 --- a/src/components/search/results/variable-view-layout/variable-results.js +++ b/src/components/search/results/variable-view-layout/variable-results.js @@ -29,6 +29,14 @@ const GRADIENT_CONSTITUENTS = [ // ] const COLOR_GRADIENT = chroma.scale(GRADIENT_CONSTITUENTS).mode("lrgb") +// Determines the order in which data sources appear in the tag list. +const DATA_SOURCES_ORDER = [ + "HEAL Studies", + "HEAL Research Programs", + "CDE", + "Non-HEAL Studies" +].map((x) => x.toLowerCase()) + const DebouncedRangeSlider = ({ value, onChange, onInternalChange=() => {}, debounce=500, ...props }) => { const [_internalValue, setInternalValue] = useState(undefined) const [internalValue] = useDebounce(_internalValue, debounce) @@ -272,15 +280,16 @@ export const VariableSearchResults = () => { const studyDataSources = useMemo(() => { const palette = new Palette(chroma.rgb(255 * .75, 255 * .25, 255 * .25), {mode: 'hex'}) - return variableStudyResults.reduce((acc, cur) => { - const dataSource = cur.data_source - if (!acc.hasOwnProperty(dataSource)) acc[dataSource] = { - count: 1, - color: palette.getNextColor() - } - else acc[dataSource].count += 1 - return acc - }, {}) + return variableStudyResults + .reduce((acc, cur) => { + const dataSource = cur.data_source + if (!acc.hasOwnProperty(dataSource)) acc[dataSource] = { + count: 1, + color: palette.getNextColor() + } + else acc[dataSource].count += 1 + return acc + }, {}) }, [variableStudyResults]) const hiddenDataSources = useMemo(() => { @@ -557,20 +566,31 @@ export const VariableSearchResults = () => {
{ - Object.entries(studyDataSources).map(([dataSource, { count, color }]) => ( - onDataSourceChange(dataSource, !checked) } - > - { `${ dataSource } (${ count })` } - + Object.keys(studyDataSources) + .sort((a, b) => { + let aIndex = DATA_SOURCES_ORDER.indexOf(a.toLowerCase()) + let bIndex = DATA_SOURCES_ORDER.indexOf(b.toLowerCase()) + // Sort unrecognized data sources alphabetically. + if (aIndex === -1 && bIndex === -1) return a.localeCompare(b) + // Put unrecognized data sources at the end of the array. + if (aIndex === -1) return 1 + if (bIndex === -1) return -1 + return aIndex - bIndex + }) + .map((dataSource) => ( + onDataSourceChange(dataSource, !checked) } + > + { `${ dataSource } (${ studyDataSources[dataSource].count })` } + )) } From e931999d18716fb4adae9441cb239ffd149f3f5d Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 20 Feb 2024 13:13:03 -0500 Subject: [PATCH 02/71] Fix bug where header navs did not append to history --- src/components/layout/layout.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/layout/layout.js b/src/components/layout/layout.js index c7d99cce..837c5484 100644 --- a/src/components/layout/layout.js +++ b/src/components/layout/layout.js @@ -1,7 +1,7 @@ import { Fragment, useState } from 'react' import { Layout as AntLayout, Button, Menu, Grid, Divider } from 'antd' import { LinkOutlined } from '@ant-design/icons' -import { useLocation, Link } from '@gatsbyjs/reach-router' +import { useLocation, useNavigate, Link } from '@gatsbyjs/reach-router' import { useEnvironment, useAnalytics, useWorkspacesAPI } from '../../contexts'; import { MobileMenu } from './menu'; import { SidePanel } from '../side-panel/side-panel'; @@ -17,6 +17,7 @@ export const Layout = ({ children }) => { const { md } = useBreakpoint() const baseLinkPath = context.workspaces_enabled === 'true' ? '/helx' : '' const location = useLocation(); + const navigate = useNavigate() // Logging out is an async operation. It's better to wait until it's complete to avoid // session persistence errors (helx-278). @@ -56,7 +57,9 @@ export const Layout = ({ children }) => { {routes.map(m => m['text'] !== '' && ( - {m.text} + navigate(`${baseLinkPath}${m.path}`) }> + {m.text} + ))} {context.workspaces_enabled && !apiLoading && ( appstoreContext.links.map((link) => ( From 3d2b8d97e4e6767389b6eaceeed79e3f23a3fdd1 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Wed, 21 Feb 2024 23:51:02 -0500 Subject: [PATCH 03/71] DOM masking prototype, useLocalStorage supports external modifications to key, site setting restoration prototype, tour prototype, etc. --- .eslintrc.json | 1 + package.json | 4 + src/app.js | 11 +- src/components/search/form/form.js | 4 +- src/components/search/results/results.js | 1 - src/contexts/index.js | 1 + src/contexts/tour-context/context.tsx | 149 +++++++++++++++++++++++ src/contexts/tour-context/index.ts | 1 + src/hooks/index.js | 3 +- src/hooks/use-local-storage.js | 76 +++++++++++- src/hooks/use-synthetic-dom-mask.tsx | 102 ++++++++++++++++ src/views/search.js | 1 - src/views/splash-screen.js | 17 ++- 13 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 src/contexts/tour-context/context.tsx create mode 100644 src/contexts/tour-context/index.ts create mode 100644 src/hooks/use-synthetic-dom-mask.tsx diff --git a/.eslintrc.json b/.eslintrc.json index 43a41c04..82cc00ac 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,6 +18,7 @@ "react/prop-types": "off", "react/react-in-jsx-scope": "off", "react/display-name": "off", + "prefer-const": "warn", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-inferrable-types": [ "error", diff --git a/package.json b/package.json index 6903c593..4ebcf69f 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,13 @@ "react-dom": "^17.0.2", "react-highlight-words": "^0.18.0", "react-infinite-scroll-component": "^6.1.0", + "react-shepherd": "^4.3.0", "react-time-until": "^1.0.6", + "shepherd.js": "^11.2.0", "styled-components": "^5.3.0", "timeago-react": "^3.0.2", "use-debounce": "^7.0.0", + "uuid": "^9.0.1", "web-vitals": "^1.0.1" }, "scripts": { @@ -72,6 +75,7 @@ "@types/node": "^18.11.9", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "babel-loader": "^9.1.2", diff --git a/src/app.js b/src/app.js index 4c5aea6a..d3816c37 100644 --- a/src/app.js +++ b/src/app.js @@ -2,7 +2,9 @@ import { useEffect } from 'react' import { LocationProvider, Router as ReachRouter, globalHistory, useLocation } from '@gatsbyjs/reach-router' import { EnvironmentProvider, ActivityProvider, AppProvider, InstanceProvider, - AnalyticsProvider, DestProvider, useEnvironment, useAnalytics, WorkspacesAPIProvider + AnalyticsProvider, DestProvider, useEnvironment, useAnalytics, WorkspacesAPIProvider, + TourProvider, + useTourContext } from './contexts' import { Layout } from './components/layout' import { NotFoundView } from './views' @@ -16,7 +18,9 @@ const ContextProviders = ({ children }) => { - {children} + + {children} + @@ -32,7 +36,8 @@ const Router = () => { const { analytics, analyticsEvents } = useAnalytics(); const location = useLocation(); const baseRouterPath = context.workspaces_enabled === 'true' ? '/helx' : '/' - + const { tour } = useTourContext() + window.tour = tour // Component mount useEffect(() => { const unlisten = globalHistory.listen(({ location }) => { diff --git a/src/components/search/form/form.js b/src/components/search/form/form.js index 7ec79e65..9ef8f370 100644 --- a/src/components/search/form/form.js +++ b/src/components/search/form/form.js @@ -211,6 +211,7 @@ export const SearchForm = ({ type=undefined, ...props }) => { notFoundContent={loadingSuggestions ? "Loading" : "No results found"} defaultActiveFirstOption={false} onSelect={handleSelect} + popupClassName="search-autocomplete-suggestions" dropdownStyle={{ display: hideAutocomplete ? "none" : undefined }} // dropdownStyle={{ display: searchCompletionDataSource === null ? "none" : undefined }} // onSearch={handleSearch} @@ -220,6 +221,7 @@ export const SearchForm = ({ type=undefined, ...props }) => { autoFocus ref={inputRef} placeholder="Enter search term" + className="search-bar" value={searchTerm} onChange={handleChangeQuery} onKeyDown={handleKeyDown} @@ -237,7 +239,7 @@ export const SearchForm = ({ type=undefined, ...props }) => { { type === FULL && ( - + ) } diff --git a/src/components/search/results/results.js b/src/components/search/results/results.js index 332f9bfd..d26c5134 100644 --- a/src/components/search/results/results.js +++ b/src/components/search/results/results.js @@ -12,7 +12,6 @@ import './results.css' export const Results = () => { const { isLoadingVariableResults, isLoadingConcepts, error, layout } = useHelxSearch() - return ( { layout === SearchLayout.EXPANDED_RESULT ? ( diff --git a/src/contexts/index.js b/src/contexts/index.js index 350e3479..43fc6f10 100644 --- a/src/contexts/index.js +++ b/src/contexts/index.js @@ -5,3 +5,4 @@ export * from './activity-context' export * from './analytics-context' export * from './dest-context' export * from './workspaces-context' +export * from './tour-context' \ No newline at end of file diff --git a/src/contexts/tour-context/context.tsx b/src/contexts/tour-context/context.tsx new file mode 100644 index 00000000..7fd8c2b3 --- /dev/null +++ b/src/contexts/tour-context/context.tsx @@ -0,0 +1,149 @@ +import { Fragment, ReactNode, createContext, useContext, useEffect, useMemo, useRef } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { useShepherdTour, Tour, ShepherdOptionsWithType } from 'react-shepherd' +import { useEnvironment } from '../environment-context' +import { SearchLayout } from '../../components/search' +import { useSyntheticDOMMask } from '../../hooks' +import 'shepherd.js/dist/css/shepherd.css' + +export interface ITourContext { + tour: any +} + +export interface ITourProvider { + children: ReactNode +} + +export const TourContext = createContext(undefined) + +function setNativeValue(element: any, value: any) { + const valueSetter = (Object as any).getOwnPropertyDescriptor(element, 'value').set; + const prototype = (Object as any).getPrototypeOf(element); + const prototypeValueSetter = (Object as any).getOwnPropertyDescriptor(prototype, 'value').set; + + if (valueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter.call(element, value); + } else { + valueSetter.call(element, value); + } +} + +export const TourProvider = ({ children }: ITourProvider ) => { + const { context } = useEnvironment() as any + + // const searchBarDomMask = useSyntheticDOMMask(".search-bar, .search-button, .search-autocomplete-suggestions") + + const tourOptions = useMemo(() => ({ + defaultStepOptions: { + cancelIcon: { + enabled: true + } + }, + useModalOverlay: true + }), []); + + const tourSteps = useMemo(() => ([ + { + id: 'intro', + attachTo: { + element: ".search-bar", + on: 'bottom' + }, + beforeShowPromise: async () => {}, + 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( +
+ You can search for biomedical concepts, studies, and variables here.

+ Try typing something and press enter. +
+ ), + when: { + show: () => {}, + hide: () => {}, + cancel: () => {}, + complete: () => {} + } + } + ]), []) + + const tour = useShepherdTour({ tourOptions, steps: tourSteps }) + + useEffect(() => { + let existingSettings = new Map() + // 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) + existingSettings.set(name, localStorage.getItem(name)) + localStorage.setItem(name, JSON.stringify(newValue)) + } + const restore = (name: string) => { + console.log("restoring", name) + const restoredValue = existingSettings.get(name)! + if (restoredValue === null) localStorage.removeItem(name) + else localStorage.setItem(name, restoredValue) + } + const overrideSettings = () => { + console.log("overriding") + override("search_history", []) + override("search_layout", SearchLayout.GRID) + } + const restoreSettings = () => { + console.log("restoring", Array.from(existingSettings.keys()).length, "settings") + Array.from(existingSettings.keys()).forEach((overridedSetting) => { + restore(overridedSetting) + }) + } + + const setup = () => { + overrideSettings() + window.addEventListener("beforeunload", cleanup) + } + const cleanup = () => { + restoreSettings() + window.removeEventListener("beforeunload", cleanup) + } + tour.on("start", setup) + tour.on("complete", cleanup) + tour.on("cancel", cleanup) + return () => { + tour.off("start", setup) + tour.off("complete", cleanup) + tour.off("cancel", cleanup) + window.removeEventListener("beforeunload", cleanup) + } + }, [tour]) + + return ( + + { children } + + ) +} + +export const useTourContext = () => useContext(TourContext) \ No newline at end of file diff --git a/src/contexts/tour-context/index.ts b/src/contexts/tour-context/index.ts new file mode 100644 index 00000000..cb7300ec --- /dev/null +++ b/src/contexts/tour-context/index.ts @@ -0,0 +1 @@ +export * from './context' \ No newline at end of file diff --git a/src/hooks/index.js b/src/hooks/index.js index f4a8dae8..8a130f89 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -3,4 +3,5 @@ export * from './use-local-storage' export * from './use-search-index' export * from './use-lunr-search' export * from './use-is-scrollable' -export * from './use-page-activity' \ No newline at end of file +export * from './use-page-activity' +export * from './use-synthetic-dom-mask' \ No newline at end of file diff --git a/src/hooks/use-local-storage.js b/src/hooks/use-local-storage.js index a100bccf..76cd4335 100644 --- a/src/hooks/use-local-storage.js +++ b/src/hooks/use-local-storage.js @@ -2,7 +2,46 @@ * Source: https://github.com/RENCI/ncdot-road-safety-client/blob/53c147c434d6d29d9ecbb0d682a3127c08086559/src/hooks/use-local-storage.js */ -import { useState } from 'react' +import { useEffect, useState } from 'react' + +/** `storage` events do not fire on the same tab. + * this code monkey patches a global, custom `localStorageModified` + * event. */ +// Note: window context preserved across HMR reloading so should not cause buggy race conditions in local development +// when modifying this file hopefully. Maybe still better safe than sorry to hard refresh the page after changing this code. +if (!window.hasOwnProperty("localStorage2")) { + window.localStorage2 = { + setItem: window.localStorage.setItem.bind(window.localStorage), + removeItem: window.localStorage.removeItem.bind(window.localStorage), + clear: window.localStorage.clear.bind(window.localStorage) + } +} +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] }, + bubbles: true, + cancelable: true + })) + !cancelled && window.localStorage2.setItem.apply(this, arguments) +} +window.localStorage.removeItem = function() { + const cancelled = window.dispatchEvent(new CustomEvent("localStorageModified", { + detail: { type: "remove", key: arguments[0] }, + bubbles: true, + cancelable: true + })) + !cancelled && window.localStorage2.removeItem.apply(this, arguments) +} +window.localStorage.clear = function() { + const cancelled = window.dispatchEvent(new CustomEvent("localStorageModified", { + detail: { type: "clear" }, + bubbles: true, + cancelable: true + })) + !cancelled && window.localStorage2.clear.apply(this, arguments) +} export const useLocalStorage = (key, initialValue) => { // State to store our value @@ -29,12 +68,45 @@ export const useLocalStorage = (key, initialValue) => { // Save state setStoredValue(valueToStore) // Save to local storage - window.localStorage.setItem(key, JSON.stringify(valueToStore)) + // Use the non-monkey patched version to avoid double-calling setState in the effect. + window.localStorage2.setItem(key, JSON.stringify(valueToStore)) } catch (error) { // A more advanced implementation would handle the error case console.log(error) } } + + useEffect(() => { + const storageCallback = (e) => { + const { type } = e.detail + let newValue + switch (type) { + case "set": { + const { key: keyEvt, value } = e.detail + if (key !== keyEvt) return + newValue = JSON.parse(value) + break + } + case "remove": { + const { key: keyEvt } = e.detail + if (key !== keyEvt) return + newValue = null + break + } + case "clear": { + newValue = null + 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) + return () => { + window.removeEventListener("localStorageModified", storageCallback) + } + }, [key]) return [storedValue, setValue] } \ No newline at end of file diff --git a/src/hooks/use-synthetic-dom-mask.tsx b/src/hooks/use-synthetic-dom-mask.tsx new file mode 100644 index 00000000..75975c56 --- /dev/null +++ b/src/hooks/use-synthetic-dom-mask.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { v4 as uuid } from 'uuid' + +export const useSyntheticDOMMask = (selector: string, boundInterval: number = 10, selectorInterval: number = 1000) => { + // const mask = useRef(document.createElement("div")) + const [elements, setElements] = useState(undefined) + const [mask, setMask] = useState(undefined) + const [elementMasks, setElementMasks] = useState | undefined>(undefined) + const [show, setShow] = useState(false) + const maskContainerId = useRef("mask-" + uuid()) + + const resize = (element: HTMLElement, bb: DOMRect) => { + element.style.top = (bb.top) + "px" + element.style.left = (bb.left) + "px" + element.style.width = (bb.width) + "px" + element.style.height = (bb.height) + "px" + } + + const computeMinimumBounds = (elements: Element[]): DOMRect => { + if (elements.length === 0) throw new Error() + let [x1, y1, x2, y2] = [Infinity, Infinity, -Infinity, -Infinity] + elements.forEach((element) => { + const bb = element.getBoundingClientRect() + if (bb.width === 0 || bb.height === 0) return + x1 = Math.min(x1, bb.x) + y1 = Math.min(y1, bb.y) + x2 = Math.max(x2, bb.right) + y2 = Math.max(y2, bb.bottom) + }) + return new DOMRect(x1, y1, x2 - x1, y2 - y1) + } + + useEffect(() => { + const interval = window.setInterval(() => { + const elements = Array.from(document.querySelectorAll(selector)) + const equal = (elems1: Element[], elems2: Element[]): boolean => { + if (elems1.length !== elems2.length) return false + return elems1.every((e1) => { + return elems2.includes(e1) + }) + } + setElements((oldElements) => { + if (oldElements === undefined) return elements + if (equal(oldElements, elements)) return oldElements + return elements + }) + }, selectorInterval) + return () => { + window.clearInterval(interval) + } + }, [selector, selectorInterval]) + + useEffect(() => { + let interval: number + if (!mask || !elementMasks) return + if (!show) mask.remove() + else { + document.body.prepend(mask) + interval = window.setInterval(() => { + const elements = Array.from(elementMasks.keys()) + elements.forEach((element) => { + const elementMask = elementMasks.get(element)! + resize(elementMask, element.getBoundingClientRect()) + }) + resize(mask, computeMinimumBounds(elements)) + }, boundInterval) + } + + return () => { + mask.remove() + window.clearInterval(interval) + } + }, [mask, elementMasks, show, boundInterval]) + + useEffect(() => { + if (!elements) return + const maskContainer = document.createElement("div") + maskContainer.id = maskContainerId.current + maskContainer.style.position = "fixed" + maskContainer.style.zIndex = "999999" + maskContainer.style.pointerEvents = "none" + + const masks = new Map() + elements.forEach((element) => { + const maskElement = document.createElement("div") + maskElement.style.backgroundColor = "transparent" + maskElement.style.position = "fixed" + maskContainer.appendChild(maskElement) + masks.set(element, maskElement) + }) + + setMask(maskContainer) + setElementMasks(masks) + + }, [elements]) + + return { + showMask: () => setShow(true), + hideMask: () => setShow(false), + selector: "#" + maskContainerId.current + } as const +} \ No newline at end of file diff --git a/src/views/search.js b/src/views/search.js index 83e76209..1819ff00 100644 --- a/src/views/search.js +++ b/src/views/search.js @@ -24,7 +24,6 @@ const ScopedSearchView = () => { // We need to override the withView useTitle since we have dynamic needs here. useTitle(query ? ["Search", query] : "") - return ( diff --git a/src/views/splash-screen.js b/src/views/splash-screen.js index b5352b51..f30dceaa 100644 --- a/src/views/splash-screen.js +++ b/src/views/splash-screen.js @@ -25,6 +25,12 @@ export const SplashScreenView = withWorkspaceAuthentication((props) => { ? `${appstoreContext.dockstore_app_specs_dir_url}/${props.app_name}/icon.png` : undefined ), [appstoreContext, props.app_name]) + + const breadcrumbs = useMemo(() => [ + { text: 'Home', path: '/helx' }, + { text: 'Workspaces', path: '/helx/workspaces' }, + { text: `${ props.app_name }`, path: `` }, + ], []) useTitle("Connecting") @@ -58,7 +64,6 @@ export const SplashScreenView = withWorkspaceAuthentication((props) => { shouldCancel = true } }, [decoded_url]) - if (loading) { return (
@@ -86,6 +91,16 @@ export const SplashScreenView = withWorkspaceAuthentication((props) => { } else { window.location = decoded_url; + return null + // return ( + //
+ //