From 14ac8e45afac06b54066da72ed011416f5ccf6cb Mon Sep 17 00:00:00 2001 From: Caroline D Date: Thu, 12 Jan 2023 14:30:13 -0800 Subject: [PATCH 01/10] Change schema viewer layout to single page Fixed #1831 --- .../js_src/lib/components/Router/Routes.tsx | 2 +- .../js_src/lib/components/Toolbar/Schema.tsx | 52 +++++++++++++------ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 8a628d1df6e..6d1dd16df25 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -45,7 +45,7 @@ export const routes: RA = [ path: ':tableName', element: () => import('../Toolbar/Schema').then( - ({ DataModelTable }) => DataModelTable + ({ DataModelRedirect }) => DataModelRedirect ), }, ], diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx index 1b7d74c323b..10b05f1c07a 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx @@ -3,7 +3,7 @@ */ import React from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import type { SortConfigs } from '../../utils/cache/definitions'; import { f } from '../../utils/functools'; @@ -32,6 +32,8 @@ import { schemaText } from '../../localization/schema'; import { LocalizedString } from 'typesafe-i18n'; import { useTitle } from '../Molecules/AppTitle'; import { getField } from '../DataModel/helpers'; +import { data } from 'jquery'; +import { Tables } from '../DataModel/types'; function Table< SORT_CONFIG extends @@ -67,8 +69,8 @@ function Table< return (
* FEATURE: adapt this page for printing */ -export function DataModelTable(): JSX.Element { - const { tableName = '' } = useParams(); +export function DataModelTable({ + tableName, +}: { + readonly tableName: keyof Tables; +}): JSX.Element { const model = getModel(tableName); - useTitle(model?.name); return model === undefined ? ( ) : ( - +
- +
); } @@ -216,7 +220,9 @@ function DataModelFields({ <>
-

{model.name}

+

+ {model.name} +

{schemaText.fields()}

getRelationships(model), [model]); return ( <> -

{schemaText.relationships()}

+

{schemaText.relationships()}

- `/specify/datamodel/${ - (relatedModel as readonly [string, JSX.Element])[0] - }/` + `#${(relatedModel as readonly [string, JSX.Element])[0]}` } headers={relationshipColumns()} sortName="dataModelRelationships" @@ -340,8 +344,8 @@ const getTables = (): RA>> => export function DataModelTables(): JSX.Element { const tables = React.useMemo(getTables, []); return ( - -
+ +

{`${welcomeText.schemaVersion()} ${getSystemInfo().schema_version}`}

@@ -368,11 +372,18 @@ export function DataModelTables(): JSX.Element {
- `/specify/datamodel/${(name as readonly [string, JSX.Element])[0]}/` + `#${(name as readonly [string, JSX.Element])[0]}` } headers={tableColumns()} sortName="dataModelTables" /> + {tables.map(({ name }, index) => ( + + ))} + <> ); } @@ -445,3 +456,12 @@ const dataModelToTsv = (): string => ] .map((line) => line.join('\t')) .join('\n'); + +export function DataModelRedirect(): null { + const { tableName = '' } = useParams(); + const navigate = useNavigate(); + React.useEffect(() => { + navigate(`/specify/data-model/#${tableName}`, { replace: true }); + }, []); + return null; +} From 0966998f81fd85fcefd7308c4b86c3875bf9d8e4 Mon Sep 17 00:00:00 2001 From: Caroline D Date: Fri, 13 Jan 2023 12:06:00 -0800 Subject: [PATCH 02/10] Add a sidebar on db model page Fixed #1831 --- .../js_src/lib/components/Toolbar/Schema.tsx | 117 ++++++++++++++---- .../lib/components/UserPreferences/Aside.tsx | 2 +- 2 files changed, 94 insertions(+), 25 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx index 10b05f1c07a..238553904f6 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx @@ -3,7 +3,7 @@ */ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import type { SortConfigs } from '../../utils/cache/definitions'; import { f } from '../../utils/functools'; @@ -30,10 +30,10 @@ import { syncFieldFormat } from '../../utils/fieldFormat'; import { formsText } from '../../localization/forms'; import { schemaText } from '../../localization/schema'; import { LocalizedString } from 'typesafe-i18n'; -import { useTitle } from '../Molecules/AppTitle'; import { getField } from '../DataModel/helpers'; -import { data } from 'jquery'; import { Tables } from '../DataModel/types'; +import { useActiveCategory, useFrozenCategory } from '../UserPreferences/Aside'; +import { locationToState } from '../Router/RouterState'; function Table< SORT_CONFIG extends @@ -46,11 +46,13 @@ function Table< headers, data: unsortedData, getLink, + className, }: { readonly sortName: SORT_CONFIG; readonly headers: RR; readonly data: RA>; readonly getLink: ((row: Row) => string) | undefined; + readonly className?: string | undefined; }): JSX.Element { const indexColumn = Object.keys(headers)[0]; const [sortConfig, handleSort, applySortConfig] = useSortConfig( @@ -69,9 +71,9 @@ function Table< return (
@@ -154,14 +156,16 @@ const booleanFormatter = (value: boolean): string => export function DataModelTable({ tableName, + forwardRef, }: { readonly tableName: keyof Tables; + readonly forwardRef?: (element: HTMLElement | null) => void; }): JSX.Element { const model = getModel(tableName); return model === undefined ? ( ) : ( -
+
@@ -227,6 +231,7 @@ function DataModelFields({

{schemaText.fields()}

); } -const tableColumns = f.store( +export const tableColumns = f.store( () => ({ name: getField(schema.models.SpLocaleContainer, 'name').label, @@ -309,7 +315,7 @@ const tableColumns = f.store( relationshipCount: schemaText.relationshipCount(), } as const) ); -const getTables = (): RA>> => +export const getTables = (): RA>> => Object.values(schema.models).map((model) => ({ name: [ model.name.toLowerCase(), @@ -343,6 +349,8 @@ const getTables = (): RA>> => export function DataModelTables(): JSX.Element { const tables = React.useMemo(getTables, []); + const { activeCategory, forwardRefs, containerRef } = useActiveCategory(); + return (
@@ -369,25 +377,86 @@ export function DataModelTables(): JSX.Element { {schemaText.downloadAsTsv()}
-
- `#${(name as readonly [string, JSX.Element])[0]}` - } - headers={tableColumns()} - sortName="dataModelTables" - /> - {tables.map(({ name }, index) => ( - - ))} - <> +
+ +
+
+ `#${(name as readonly [string, JSX.Element])[0]}` + } + headers={tableColumns()} + sortName="dataModelTables" + /> + {tables.map(({ name }, index) => ( + + ))} + + ); } +export function DataModelAside({ + activeCategory, +}: { + readonly activeCategory: number; +}): JSX.Element { + const tables = React.useMemo(getTables, []); + const [freezeCategory, setFreezeCategory] = useFrozenCategory(); + const currentIndex = freezeCategory ?? activeCategory; + const navigate = useNavigate(); + const location = useLocation(); + const state = locationToState(location, 'BackgroundLocation'); + const isInOverlay = typeof state === 'object'; + + React.useEffect( + () => + isInOverlay + ? undefined + : navigate( + `/specify/data-model/#${ + (tables[activeCategory].name as readonly [string, JSX.Element])[0] + }`, + { + replace: true, + } + ), + [isInOverlay, tables, activeCategory] + ); + + return ( + + ); +} + const dataModelToTsv = (): string => [ [ diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx index c1d660810b8..35759db3828 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx @@ -130,7 +130,7 @@ export function PreferencesAside({ * This hack temporary makes the clicked category active, until user starts to * scroll away */ -function useFrozenCategory(): GetSet { +export function useFrozenCategory(): GetSet { const [freezeCategory, setFreezeCategory] = React.useState< number | undefined >(undefined); From 233b19f27e9f6ecbd9f13ce3e77665db0fa54931 Mon Sep 17 00:00:00 2001 From: Caroline D Date: Fri, 13 Jan 2023 12:30:10 -0800 Subject: [PATCH 03/10] Hide side bar menu on small screens --- .../frontend/js_src/lib/components/Toolbar/Schema.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx index 238553904f6..0e5c941ac03 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx @@ -435,9 +435,9 @@ export function DataModelAside({ return (
void; - readonly containerRef: React.RefCallback; + readonly scrollContainerRef: React.RefCallback; } { const [activeCategory, setActiveCategory] = React.useState(0); const observer = React.useRef(undefined); @@ -43,7 +43,7 @@ export function useActiveCategory(): { references.current[index] = element ?? undefined; if (element !== null) observer?.current?.observe(element); }, []), - containerRef: React.useCallback((container): void => { + scrollContainerRef: React.useCallback((container): void => { observer.current?.disconnect(); observer.current = new IntersectionObserver( @@ -75,10 +75,8 @@ export function useActiveCategory(): { } export function PreferencesAside({ - id, activeCategory, }: { - readonly id: (prefix: string) => string; readonly activeCategory: number; }): JSX.Element { const definitions = usePrefDefinitions(); @@ -86,17 +84,18 @@ export function PreferencesAside({ const location = useLocation(); const state = locationToState(location, 'BackgroundLocation'); const isInOverlay = typeof state === 'object'; + // Don't call navigate while an overlay is open as that will close the overlay React.useEffect( () => isInOverlay ? undefined : navigate( - `/specify/user-preferences/#${id(definitions[activeCategory][0])}`, + `/specify/user-preferences/#${definitions[activeCategory][0]}`, { replace: true, } ), - [isInOverlay, definitions, activeCategory, id] + [isInOverlay, definitions, activeCategory] ); const [freezeCategory, setFreezeCategory] = useFrozenCategory(); @@ -112,7 +111,7 @@ export function PreferencesAside({ {definitions.map(([category, { title }], index) => ( setFreezeCategory(index)} > diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx index de2c9dbf6aa..5111873ddcf 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx @@ -8,7 +8,6 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { useBooleanState } from '../../hooks/useBooleanState'; -import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { preferencesText } from '../../localization/preferences'; import { StringToJsx } from '../../localization/utils'; @@ -42,7 +41,6 @@ function Preferences(): JSX.Element { const [needsRestart, handleRestartNeeded] = useBooleanState(); const loading = React.useContext(LoadingContext); - const id = useId('preferences'); const navigate = useNavigate(); React.useEffect( @@ -54,7 +52,8 @@ function Preferences(): JSX.Element { [handleChangesMade, handleRestartNeeded] ); - const { activeCategory, forwardRefs, containerRef } = useActiveCategory(); + const { activeCategory, forwardRefs, scrollContainerRef } = + useActiveCategory(); return ( @@ -73,14 +72,10 @@ function Preferences(): JSX.Element { >
- - + +
@@ -129,11 +124,9 @@ export function usePrefDefinitions() { } export function PreferencesContent({ - id, isReadOnly, forwardRefs, }: { - readonly id: (prefix: string) => string; readonly isReadOnly: boolean; readonly forwardRefs?: (index: number, element: HTMLElement | null) => void; }): JSX.Element { @@ -149,7 +142,7 @@ export function PreferencesContent({

{title}

{description !== undefined &&

{description}

} From a00a8b7112dd15e9d9bcf797536793a0522895e5 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 16 Jan 2023 11:57:58 -0600 Subject: [PATCH 05/10] Rewrite useActiveCategory hook --- .../js_src/lib/components/Toolbar/Schema.tsx | 15 +-- .../lib/components/UserPreferences/Aside.tsx | 114 +++++++++--------- .../lib/components/UserPreferences/index.tsx | 5 +- 3 files changed, 66 insertions(+), 68 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx index 36eb4756bba..344ab288b98 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx @@ -150,10 +150,6 @@ const parser = f.store(() => const booleanFormatter = (value: boolean): string => syncFieldFormat(undefined, parser(), value); -/* - * FEATURE: adapt this page for printing - */ - function DataModelTable({ tableName, forwardRef, @@ -349,8 +345,7 @@ export const getTables = (): RA>> => export function DataModelTables(): JSX.Element { const tables = React.useMemo(getTables, []); - const { activeCategory, forwardRefs, scrollContainerRef } = - useActiveCategory(); + const { visibleChild, forwardRefs, scrollContainerRef } = useActiveCategory(); return ( @@ -378,8 +373,8 @@ export function DataModelTables(): JSX.Element { {schemaText.downloadAsTsv()}
-
- +
+
- isInOverlay + isInOverlay || activeCategory === undefined ? undefined : navigate( `/specify/data-model/#${ diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx index 10789975fec..acae23fddf3 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx @@ -1,83 +1,87 @@ import React from 'react'; +import { useLocation } from 'react-router'; import { useNavigate } from 'react-router-dom'; +import _ from 'underscore'; import { listen } from '../../utils/events'; -import { f } from '../../utils/functools'; import type { GetSet, WritableArray } from '../../utils/types'; -import { filterArray } from '../../utils/types'; import { Link } from '../Atoms/Link'; -import { usePrefDefinitions } from './index'; -import { useLocation } from 'react-router'; import { locationToState } from '../Router/RouterState'; +import { usePrefDefinitions } from './index'; -/** Update the active category on the sidebar as user scrolls */ +/** + * Update the active category on the sidebar as user scrolls + * + * Previous implementation used IntersectionObserver for this, but it was too + * slow when you have 180 categories (on the Data Model page) + */ export function useActiveCategory(): { - readonly activeCategory: number; + readonly visibleChild: number | undefined; readonly forwardRefs: (index: number, element: HTMLElement | null) => void; readonly scrollContainerRef: React.RefCallback; } { - const [activeCategory, setActiveCategory] = React.useState(0); - const observer = React.useRef(undefined); + const [activeCategory, setActiveCategory] = React.useState< + number | undefined + >(0); const references = React.useRef>([]); - React.useEffect(() => () => observer.current?.disconnect(), []); - // eslint-disable-next-line functional/prefer-readonly-type - const intersecting = React.useRef>(new Set()); + const [container, setContainer] = React.useState(null); + React.useEffect(() => { + if (container === null) return undefined; + + function rawHandleChange(): void { + if (container === null) return; + const { x, y } = container.getBoundingClientRect(); + const visibleElement = document.elementFromPoint( + x, + y + marginTop + ) as HTMLElement; + if (visibleElement === null) return; + const section = findSection(container, visibleElement); + if (section === undefined) return; + const index = references.current.indexOf(section); + setActiveCategory(index === -1 ? undefined : index); + } - function handleObserved({ - isIntersecting, - target, - }: IntersectionObserverEntry): void { - const index = references.current.indexOf(target as HTMLElement); - intersecting.current[isIntersecting ? 'add' : 'delete'](index); - const intersection = f.min(...Array.from(intersecting.current)) ?? 0; - setActiveCategory(intersection); - } + const handleChange = _.throttle(rawHandleChange, scrollThrottle); + + const observer = new ResizeObserver(handleChange); + observer.observe(container); + const scroll = listen(container, 'scroll', handleChange); + return (): void => { + observer.disconnect(); + scroll(); + }; + }, [container]); return { - activeCategory, + visibleChild: activeCategory, forwardRefs: React.useCallback((index, element) => { - const oldElement = references.current[index]; - if (typeof oldElement === 'object') - observer.current?.unobserve(oldElement); references.current[index] = element ?? undefined; - if (element !== null) observer?.current?.observe(element); - }, []), - scrollContainerRef: React.useCallback((container): void => { - observer.current?.disconnect(); - - observer.current = new IntersectionObserver( - (entries) => entries.map(handleObserved), - { - root: container, - rootMargin: '-200px 0px -100px 0px', - threshold: 0, - } - ); - /* - * Since React 18, apps running in strict mode in development are mounted - * followed immediately by an unmount and then mount again. This causes - * observer not to fire. Can be fixed by either - * running React in non-strict mode (bad idea), or wrapping the following - * in setTimeout(()=>..., 0); - * More info: - * https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-strict-mode - */ - setTimeout( - () => - filterArray(references.current).forEach((value) => - observer.current?.observe(value) - ), - 0 - ); }, []), + scrollContainerRef: setContainer, }; } +/** + * Look for an element this many pixels below the top of the scroll container + */ +const marginTop = 200; +const scrollThrottle = 50; + +function findSection( + container: HTMLElement, + child: HTMLElement +): HTMLElement | undefined { + const parent = child.parentElement; + if (parent === container) return child; + else if (parent === null) return undefined; + else return findSection(container, parent); +} export function PreferencesAside({ activeCategory, }: { - readonly activeCategory: number; + readonly activeCategory: number | undefined; }): JSX.Element { const definitions = usePrefDefinitions(); const navigate = useNavigate(); @@ -87,7 +91,7 @@ export function PreferencesAside({ // Don't call navigate while an overlay is open as that will close the overlay React.useEffect( () => - isInOverlay + isInOverlay || activeCategory === undefined ? undefined : navigate( `/specify/user-preferences/#${definitions[activeCategory][0]}`, diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx index 5111873ddcf..d927346d1ab 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx @@ -52,8 +52,7 @@ function Preferences(): JSX.Element { [handleChangesMade, handleRestartNeeded] ); - const { activeCategory, forwardRefs, scrollContainerRef } = - useActiveCategory(); + const { visibleChild, forwardRefs, scrollContainerRef } = useActiveCategory(); return ( @@ -74,7 +73,7 @@ function Preferences(): JSX.Element { className="relative flex flex-col gap-6 overflow-y-auto md:flex-row" ref={scrollContainerRef} > - +
From 702989139a646ed972d8d310c707b4087020f2bb Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 16 Jan 2023 12:01:15 -0600 Subject: [PATCH 06/10] Rename useActiveCategory hook --- .../js_src/lib/components/Toolbar/Schema.tsx | 5 +- .../lib/components/UserPreferences/Aside.tsx | 72 +----------------- .../lib/components/UserPreferences/index.tsx | 5 +- .../UserPreferences/useTopChild.tsx | 76 +++++++++++++++++++ 4 files changed, 83 insertions(+), 75 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/UserPreferences/useTopChild.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx index 344ab288b98..682c86453ab 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx @@ -32,8 +32,9 @@ import { schemaText } from '../../localization/schema'; import { LocalizedString } from 'typesafe-i18n'; import { getField } from '../DataModel/helpers'; import { Tables } from '../DataModel/types'; -import { useActiveCategory, useFrozenCategory } from '../UserPreferences/Aside'; +import { useFrozenCategory } from '../UserPreferences/Aside'; import { locationToState } from '../Router/RouterState'; +import { useTopChild } from '../UserPreferences/useTopChild'; function Table< SORT_CONFIG extends @@ -345,7 +346,7 @@ export const getTables = (): RA>> => export function DataModelTables(): JSX.Element { const tables = React.useMemo(getTables, []); - const { visibleChild, forwardRefs, scrollContainerRef } = useActiveCategory(); + const { visibleChild, forwardRefs, scrollContainerRef } = useTopChild(); return ( diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx index acae23fddf3..876523593e4 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/Aside.tsx @@ -1,83 +1,13 @@ import React from 'react'; import { useLocation } from 'react-router'; import { useNavigate } from 'react-router-dom'; -import _ from 'underscore'; import { listen } from '../../utils/events'; -import type { GetSet, WritableArray } from '../../utils/types'; +import type { GetSet } from '../../utils/types'; import { Link } from '../Atoms/Link'; import { locationToState } from '../Router/RouterState'; import { usePrefDefinitions } from './index'; -/** - * Update the active category on the sidebar as user scrolls - * - * Previous implementation used IntersectionObserver for this, but it was too - * slow when you have 180 categories (on the Data Model page) - */ -export function useActiveCategory(): { - readonly visibleChild: number | undefined; - readonly forwardRefs: (index: number, element: HTMLElement | null) => void; - readonly scrollContainerRef: React.RefCallback; -} { - const [activeCategory, setActiveCategory] = React.useState< - number | undefined - >(0); - const references = React.useRef>([]); - - const [container, setContainer] = React.useState(null); - React.useEffect(() => { - if (container === null) return undefined; - - function rawHandleChange(): void { - if (container === null) return; - const { x, y } = container.getBoundingClientRect(); - const visibleElement = document.elementFromPoint( - x, - y + marginTop - ) as HTMLElement; - if (visibleElement === null) return; - const section = findSection(container, visibleElement); - if (section === undefined) return; - const index = references.current.indexOf(section); - setActiveCategory(index === -1 ? undefined : index); - } - - const handleChange = _.throttle(rawHandleChange, scrollThrottle); - - const observer = new ResizeObserver(handleChange); - observer.observe(container); - const scroll = listen(container, 'scroll', handleChange); - return (): void => { - observer.disconnect(); - scroll(); - }; - }, [container]); - - return { - visibleChild: activeCategory, - forwardRefs: React.useCallback((index, element) => { - references.current[index] = element ?? undefined; - }, []), - scrollContainerRef: setContainer, - }; -} - -/** - * Look for an element this many pixels below the top of the scroll container - */ -const marginTop = 200; -const scrollThrottle = 50; - -function findSection( - container: HTMLElement, - child: HTMLElement -): HTMLElement | undefined { - const parent = child.parentElement; - if (parent === container) return child; - else if (parent === null) return undefined; - else return findSection(container, parent); -} export function PreferencesAside({ activeCategory, }: { diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx index d927346d1ab..c2cce297f85 100644 --- a/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/index.tsx @@ -20,7 +20,7 @@ import { Submit } from '../Atoms/Submit'; import { LoadingContext } from '../Core/Contexts'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { hasPermission } from '../Permissions/helpers'; -import { PreferencesAside, useActiveCategory } from './Aside'; +import { PreferencesAside } from './Aside'; import type { GenericPreferencesCategories, PreferenceItem, @@ -35,6 +35,7 @@ import { import { prefEvents } from './Hooks'; import { DefaultPreferenceItemRender } from './Renderers'; import { usePref } from './usePref'; +import { useTopChild } from './useTopChild'; function Preferences(): JSX.Element { const [changesMade, handleChangesMade] = useBooleanState(); @@ -52,7 +53,7 @@ function Preferences(): JSX.Element { [handleChangesMade, handleRestartNeeded] ); - const { visibleChild, forwardRefs, scrollContainerRef } = useActiveCategory(); + const { visibleChild, forwardRefs, scrollContainerRef } = useTopChild(); return ( diff --git a/specifyweb/frontend/js_src/lib/components/UserPreferences/useTopChild.tsx b/specifyweb/frontend/js_src/lib/components/UserPreferences/useTopChild.tsx new file mode 100644 index 00000000000..b0915b25454 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/UserPreferences/useTopChild.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import _ from 'underscore'; + +import { listen } from '../../utils/events'; +import type { WritableArray } from '../../utils/types'; + +/** + * In a container with several children and a scroll bar, detect which + * child is currently at the top of the view port (200 pixels from the top) + * + * Previous implementation used IntersectionObserver for this, but it was too + * slow when you have 180 categories (on the Data Model page) + */ +export function useTopChild(): { + readonly visibleChild: number | undefined; + readonly forwardRefs: (index: number, element: HTMLElement | null) => void; + readonly scrollContainerRef: React.RefCallback; +} { + const [activeCategory, setActiveCategory] = React.useState< + number | undefined + >(undefined); + const references = React.useRef>([]); + + const [container, setContainer] = React.useState(null); + React.useEffect(() => { + if (container === null) return undefined; + + function rawHandleChange(): void { + if (container === null) return; + const { x, y } = container.getBoundingClientRect(); + const visibleElement = document.elementFromPoint( + x, + y + marginTop + ) as HTMLElement; + if (visibleElement === null) return; + const section = findSection(container, visibleElement); + if (section === undefined) return; + const index = references.current.indexOf(section); + setActiveCategory(index === -1 ? undefined : index); + } + + const handleChange = _.throttle(rawHandleChange, scrollThrottle); + + const observer = new ResizeObserver(handleChange); + observer.observe(container); + const scroll = listen(container, 'scroll', handleChange); + return (): void => { + observer.disconnect(); + scroll(); + }; + }, [container]); + + return { + visibleChild: activeCategory, + forwardRefs: React.useCallback((index, element) => { + references.current[index] = element ?? undefined; + }, []), + scrollContainerRef: setContainer, + }; +} + +/** + * Look for an element this many pixels below the top of the scroll container + */ +const marginTop = 200; +const scrollThrottle = 50; + +function findSection( + container: HTMLElement, + child: HTMLElement +): HTMLElement | undefined { + const parent = child.parentElement; + if (parent === container) return child; + else if (parent === null) return undefined; + else return findSection(container, parent); +} From 6429beafde38667f6dd6f50cd881849bc42b0a60 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 16 Jan 2023 12:01:42 -0600 Subject: [PATCH 07/10] Rename Schema page to DataModel To disambiguate from Schema Config --- specifyweb/frontend/js_src/lib/components/Router/Routes.tsx | 4 ++-- .../lib/components/Toolbar/{Schema.tsx => DataModel.tsx} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename specifyweb/frontend/js_src/lib/components/Toolbar/{Schema.tsx => DataModel.tsx} (100%) diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 6d1dd16df25..fe64555c1aa 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -37,14 +37,14 @@ export const routes: RA = [ index: true, title: schemaText.databaseSchema(), element: () => - import('../Toolbar/Schema').then( + import('../Toolbar/DataModel').then( ({ DataModelTables }) => DataModelTables ), }, { path: ':tableName', element: () => - import('../Toolbar/Schema').then( + import('../Toolbar/DataModel').then( ({ DataModelRedirect }) => DataModelRedirect ), }, diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx similarity index 100% rename from specifyweb/frontend/js_src/lib/components/Toolbar/Schema.tsx rename to specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx From 18648e2065bd343372796c5bb4679e9a7a164586 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 16 Jan 2023 13:21:24 -0600 Subject: [PATCH 08/10] Add a Go To Top link to Data Model viewer --- .../lib/components/Toolbar/DataModel.tsx | 90 ++++++++++--------- .../js_src/lib/localization/schema.ts | 3 + 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx index 682c86453ab..120843f1aec 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx @@ -4,36 +4,36 @@ import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import type { LocalizedString } from 'typesafe-i18n'; +import { formsText } from '../../localization/forms'; +import { schemaText } from '../../localization/schema'; +import { welcomeText } from '../../localization/welcome'; import type { SortConfigs } from '../../utils/cache/definitions'; +import { syncFieldFormat } from '../../utils/fieldFormat'; import { f } from '../../utils/functools'; -import { welcomeText } from '../../localization/welcome'; -import { getModel, schema } from '../DataModel/schema'; -import { - javaTypeToHuman, - localizedRelationshipTypes, -} from '../SchemaConfig/helpers'; -import type { SpecifyModel } from '../DataModel/specifyModel'; -import { getSystemInfo } from '../InitialContext/systemInfo'; -import type { RA, RR } from '../../utils/types'; import { resolveParser } from '../../utils/parser/definitions'; -import { downloadFile } from '../Molecules/FilePicker'; -import { formatNumber } from '../Atoms/Internationalization'; -import { NotFoundView } from '../Router/NotFoundView'; +import type { RA, RR } from '../../utils/types'; +import { Container, H2, H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; +import { formatNumber } from '../Atoms/Internationalization'; import { Link } from '../Atoms/Link'; -import { Container, H2, H3 } from '../Atoms'; +import { getField } from '../DataModel/helpers'; +import { getModel, schema } from '../DataModel/schema'; +import type { SpecifyModel } from '../DataModel/specifyModel'; +import type { Tables } from '../DataModel/types'; import { softFail } from '../Errors/Crash'; -import { TableIcon } from '../Molecules/TableIcon'; +import { getSystemInfo } from '../InitialContext/systemInfo'; +import { downloadFile } from '../Molecules/FilePicker'; import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; -import { syncFieldFormat } from '../../utils/fieldFormat'; -import { formsText } from '../../localization/forms'; -import { schemaText } from '../../localization/schema'; -import { LocalizedString } from 'typesafe-i18n'; -import { getField } from '../DataModel/helpers'; -import { Tables } from '../DataModel/types'; -import { useFrozenCategory } from '../UserPreferences/Aside'; +import { TableIcon } from '../Molecules/TableIcon'; +import { NotFoundView } from '../Router/NotFoundView'; import { locationToState } from '../Router/RouterState'; +import { + javaTypeToHuman, + localizedRelationshipTypes, +} from '../SchemaConfig/helpers'; +import { useFrozenCategory } from '../UserPreferences/Aside'; import { useTopChild } from '../UserPreferences/useTopChild'; function Table< @@ -47,7 +47,7 @@ function Table< headers, data: unsortedData, getLink, - className, + className = '', }: { readonly sortName: SORT_CONFIG; readonly headers: RR; @@ -74,7 +74,8 @@ function Table< className={` grid-table w-fit flex-1 grid-cols-[repeat(var(--cols),auto)] rounded border border-gray-400 dark:border-neutral-500 - ${className}`} + ${className} + `} role="table" style={{ '--cols': Object.keys(headers).length } as React.CSSProperties} > @@ -151,6 +152,8 @@ const parser = f.store(() => const booleanFormatter = (value: boolean): string => syncFieldFormat(undefined, parser(), value); +const topId = 'tables'; + function DataModelTable({ tableName, forwardRef, @@ -163,6 +166,15 @@ function DataModelTable({ ) : (
+
+
+ +

+ {model.name} +

+
+ {schemaText.goToTop()} +
@@ -219,16 +231,9 @@ function DataModelFields({ const data = React.useMemo(() => getFields(model), [model]); return ( <> -
- -

- {model.name} -

-

{schemaText.fields()}

); @@ -380,19 +384,21 @@ export function DataModelTables(): JSX.Element { className="ml-2 flex flex-col gap-2 overflow-y-auto" ref={scrollContainerRef} > -
- `#${(name as readonly [string, JSX.Element])[0]}` - } - headers={tableColumns()} - sortName="dataModelTables" - /> +
+
+ `#${(name as readonly [string, JSX.Element])[0]}` + } + headers={tableColumns()} + sortName="dataModelTables" + /> + {tables.map(({ name }, index) => ( ))} @@ -441,10 +447,10 @@ export function DataModelAside({ return ( setFreezeCategory(index)} - className="!justify-start" > {jsxName} diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 0ba68b55133..941a0214209 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -282,4 +282,7 @@ export const schemaText = createDictionary({ 'fr-fr': 'Tableaux possibles', 'uk-ua': 'Можливі таблиці', }, + goToTop: { + 'en-us': 'Go to top', + }, } as const); From e4ee98948a2a9e6422a58673b7a63dbc786450bf Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 16 Jan 2023 15:34:55 -0600 Subject: [PATCH 09/10] Fixes #2815 This fix also properly creates a new line for every field rather than leaving the array in a cell --- .../lib/components/Toolbar/DataModel.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx index 120843f1aec..f28bf8c46f2 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx @@ -359,24 +359,24 @@ export function DataModelTables(): JSX.Element { {`${welcomeText.schemaVersion()} ${getSystemInfo().schema_version}`} - {schemaText.downloadAsJson()} - - + void downloadFile( - `Specify 7 datamodel - v${getSystemInfo().schema_version}.tsv`, + `Specify 7 Data Model - v${getSystemInfo().schema_version}.tsv`, dataModelToTsv() ).catch(softFail) } > {schemaText.downloadAsTsv()} - +
@@ -485,16 +485,16 @@ const dataModelToTsv = (): string => ...Object.values(schema.models).flatMap((model) => { const commonColumns = [ model.name, - model.label, + model.label.replace('\n', ' '), booleanFormatter(model.isSystem), booleanFormatter(model.isHidden), model.tableId, ]; return [ - model.literalFields.map((field) => [ + ...model.literalFields.map((field) => [ ...commonColumns, field.name, - field.label, + field.label.replace('\n', ' '), field.getLocalizedDesc(), booleanFormatter(field.isHidden), booleanFormatter(field.isReadOnly), @@ -507,10 +507,10 @@ const dataModelToTsv = (): string => '', '', ]), - model.relationships.map((relationship) => [ + ...model.relationships.map((relationship) => [ ...commonColumns, relationship.name, - relationship.label, + relationship.label.replace('\n', ' '), relationship.getLocalizedDesc(), booleanFormatter(relationship.isHidden), booleanFormatter(relationship.isReadOnly), From 6d3c588d0e4d1d613e5cd7a97dc35bc25984c383 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Thu, 19 Jan 2023 22:55:05 -0600 Subject: [PATCH 10/10] Make DataModel viewer typing safer --- .../lib/components/Toolbar/DataModel.tsx | 215 +++++++++--------- 1 file changed, 108 insertions(+), 107 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx index f28bf8c46f2..5dd8f17863c 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/DataModel.tsx @@ -13,7 +13,8 @@ import type { SortConfigs } from '../../utils/cache/definitions'; import { syncFieldFormat } from '../../utils/fieldFormat'; import { f } from '../../utils/functools'; import { resolveParser } from '../../utils/parser/definitions'; -import type { RA, RR } from '../../utils/types'; +import type { IR, RA, RR } from '../../utils/types'; +import { ensure } from '../../utils/types'; import { Container, H2, H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { formatNumber } from '../Atoms/Internationalization'; @@ -41,7 +42,8 @@ function Table< | 'dataModelFields' | 'dataModelRelationships' | 'dataModelTables', - FIELD_NAME extends SortConfigs[SORT_CONFIG] + FIELD_NAME extends SortConfigs[SORT_CONFIG], + DATA extends Row> >({ sortName, headers, @@ -51,8 +53,8 @@ function Table< }: { readonly sortName: SORT_CONFIG; readonly headers: RR; - readonly data: RA>; - readonly getLink: ((row: Row) => string) | undefined; + readonly data: RA; + readonly getLink: ((row: DATA) => string) | undefined; readonly className?: string | undefined; }): JSX.Element { const indexColumn = Object.keys(headers)[0]; @@ -202,26 +204,29 @@ type Value = | string | readonly [number | string | undefined, JSX.Element] | undefined; -type Row = RR; -const getFields = ( - model: SpecifyModel -): RA>> => - model.literalFields.map((field) => ({ - name: field.name, - label: field.label, - description: field.getLocalizedDesc(), - isHidden: booleanFormatter(field.isHidden), - isReadOnly: booleanFormatter(field.isReadOnly), - isRequired: booleanFormatter(field.isRequired), - type: javaTypeToHuman(field.type, undefined), - length: [ - field.length, - - {f.maybe(field.length, formatNumber)} - , - ], - databaseColumn: field.databaseColumn, - })); +type Row> = SHAPE; +const getFields = (model: SpecifyModel) => + ensure, Value>>>>()( + model.literalFields.map( + (field) => + ({ + name: field.name, + label: field.label, + description: field.getLocalizedDesc(), + isHidden: booleanFormatter(field.isHidden), + isReadOnly: booleanFormatter(field.isReadOnly), + isRequired: booleanFormatter(field.isRequired), + type: javaTypeToHuman(field.type, undefined), + length: [ + field.length, + + {f.maybe(field.length, formatNumber)} + , + ], + databaseColumn: field.databaseColumn, + } as const) + ) + ); function DataModelFields({ model, @@ -260,28 +265,31 @@ const relationshipColumns = f.store( } as const) ); -const getRelationships = ( - model: SpecifyModel -): RA>> => - model.relationships.map((field) => ({ - name: field.name, - label: field.label, - description: field.getLocalizedDesc(), - isHidden: booleanFormatter(field.isHidden), - isReadOnly: booleanFormatter(field.isReadOnly), - isRequired: booleanFormatter(field.isRequired), - type: localizedRelationshipTypes[field.type] ?? field.type, - databaseColumn: field.databaseColumn, - relatedModel: [ - field.relatedModel.name.toLowerCase(), - <> - - {field.relatedModel.name} - , - ], - otherSideName: field.otherSideName, - isDependent: booleanFormatter(field.isDependent()), - })); +const getRelationships = (model: SpecifyModel) => + ensure, Value>>>>()( + model.relationships.map( + (field) => + ({ + name: field.name, + label: field.label, + description: field.getLocalizedDesc(), + isHidden: booleanFormatter(field.isHidden), + isReadOnly: booleanFormatter(field.isReadOnly), + isRequired: booleanFormatter(field.isRequired), + type: localizedRelationshipTypes[field.type] ?? field.type, + databaseColumn: field.databaseColumn, + relatedModel: [ + field.relatedModel.name.toLowerCase(), + <> + + {field.relatedModel.name} + , + ], + otherSideName: field.otherSideName, + isDependent: booleanFormatter(field.isDependent()), + } as const) + ) + ); function DataModelRelationships({ model, @@ -294,9 +302,7 @@ function DataModelRelationships({

{schemaText.relationships()}

- `#${(relatedModel as readonly [string, JSX.Element])[0]}` - } + getLink={({ relatedModel }): string => `#${relatedModel[0]}`} headers={relationshipColumns()} sortName="dataModelRelationships" /> @@ -304,7 +310,7 @@ function DataModelRelationships({ ); } -export const tableColumns = f.store( +const tableColumns = f.store( () => ({ name: getField(schema.models.SpLocaleContainer, 'name').label, @@ -316,37 +322,42 @@ export const tableColumns = f.store( relationshipCount: schemaText.relationshipCount(), } as const) ); -export const getTables = (): RA>> => - Object.values(schema.models).map((model) => ({ - name: [ - model.name.toLowerCase(), - <> - - {model.name} - , - ], - label: model.label, - isSystem: booleanFormatter(model.isSystem), - isHidden: booleanFormatter(model.isHidden), - tableId: [ - model.tableId, - - {model.tableId} - , - ], - fieldCount: [ - model.fields.length, - - {formatNumber(model.fields.length)} - , - ], - relationshipCount: [ - model.relationships.length, - - {formatNumber(model.relationships.length)} - , - ], - })); +const getTables = () => + ensure, Value>>>>()( + Object.values(schema.models).map( + (model) => + ({ + name: [ + model.name.toLowerCase(), + <> + + {model.name} + , + ], + label: model.label, + isSystem: booleanFormatter(model.isSystem), + isHidden: booleanFormatter(model.isHidden), + tableId: [ + model.tableId, + + {model.tableId} + , + ], + fieldCount: [ + model.fields.length, + + {formatNumber(model.fields.length)} + , + ], + relationshipCount: [ + model.relationships.length, + + {formatNumber(model.relationships.length)} + , + ], + } as const) + ) + ); export function DataModelTables(): JSX.Element { const tables = React.useMemo(getTables, []); @@ -387,9 +398,7 @@ export function DataModelTables(): JSX.Element {
- `#${(name as readonly [string, JSX.Element])[0]}` - } + getLink={({ name }): string => `#${name[0]}`} headers={tableColumns()} sortName="dataModelTables" /> @@ -398,7 +407,7 @@ export function DataModelTables(): JSX.Element { ))} @@ -424,14 +433,9 @@ export function DataModelAside({ () => isInOverlay || activeCategory === undefined ? undefined - : navigate( - `/specify/data-model/#${ - (tables[activeCategory].name as readonly [string, JSX.Element])[0] - }`, - { - replace: true, - } - ), + : navigate(`/specify/data-model/#${tables[activeCategory].name[0]}`, { + replace: true, + }), [isInOverlay, tables, activeCategory] ); @@ -442,20 +446,17 @@ export function DataModelAside({ divide-[color:var(--form-background)] overflow-y-auto md:flex `} > - {tables.map(({ name }, index) => { - const [tableName, jsxName] = name as readonly [string, JSX.Element]; - return ( - setFreezeCategory(index)} - > - {jsxName} - - ); - })} + {tables.map(({ name: [tableName, jsxName] }, index) => ( + setFreezeCategory(index)} + > + {jsxName} + + ))} ); }