From 04cb59eb53f07bcca90d6a06946a333e2f346177 Mon Sep 17 00:00:00 2001 From: Weyert de Boer <7049+weyert@users.noreply.github.com> Date: Fri, 26 Jul 2024 18:58:24 +0100 Subject: [PATCH 1/2] feat: add ability to Expand/Collapse All This introduces a new property named `showExpandAll` (defaults to `false`) that display a button that allows to easily expand or collapse all rows. The button shows up in both the breadcrumbs toolbar as above the top schema row. Replace `jotai` related deprecated functions to the suggested new alternative functions to avoid the spamming of deprecation warnings in the developer console. --- src/__stories__/Default.tsx | 24 +++++++--- src/components/JsonSchemaViewer.tsx | 41 ++++++++++++++--- src/components/PathCrumbs/index.tsx | 46 ++++++++++++++++--- src/components/SchemaRow/SchemaRow.tsx | 19 ++++++-- .../SchemaRow/TopLevelSchemaRow.tsx | 5 +- src/components/SchemaRow/state.ts | 3 ++ src/contexts/jsvOptions.tsx | 2 + 7 files changed, 113 insertions(+), 27 deletions(-) diff --git a/src/__stories__/Default.tsx b/src/__stories__/Default.tsx index 0f1d1d4e..c20e8036 100644 --- a/src/__stories__/Default.tsx +++ b/src/__stories__/Default.tsx @@ -7,6 +7,7 @@ import { JsonSchemaProps, JsonSchemaViewer } from '../components/JsonSchemaViewe const defaultSchema = require('../__fixtures__/default-schema.json'); const stressSchema = require('../__fixtures__/stress-schema.json'); +const githubIssueSchema = require('../__fixtures__/real-world/github-issue.json'); const arrayOfComplexObjects = require('../__fixtures__/arrays/of-complex-objects.json'); export default { @@ -22,12 +23,16 @@ export const Default = Template.bind({}); export const CustomRowAddon = Template.bind({}); CustomRowAddon.args = { - renderRowAddon: () => ( - - + + ) : null; + }, }; export const Expansions = Template.bind({}); @@ -88,3 +93,10 @@ export const DarkMode: Story = ({ schema = defaultSchema as JSO ); + +export const ExpandCollapseAll = Template.bind({}); +ExpandCollapseAll.args = { + showExpandAll: true, + schema: githubIssueSchema as JSONSchema4, +}; +ExpandCollapseAll.storyName = 'Expand/Collapse All'; diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 6a8adbd1..7148cf1d 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -4,11 +4,10 @@ import { SchemaTree as JsonSchemaTree, SchemaTreeRefDereferenceFn, } from '@stoplight/json-schema-tree'; -import { Box, Provider as MosaicProvider } from '@stoplight/mosaic'; +import { Box, Button, HStack, Provider as MosaicProvider } from '@stoplight/mosaic'; import { ErrorBoundaryForwardedProps, FallbackProps, withErrorBoundary } from '@stoplight/react-error-boundary'; import cn from 'classnames'; -import { Provider } from 'jotai'; -import { useUpdateAtom } from 'jotai/utils'; +import { Provider, useAtom, useSetAtom } from 'jotai'; import * as React from 'react'; import { JSVOptions, JSVOptionsContextProvider } from '../contexts'; @@ -16,7 +15,7 @@ import { shouldNodeBeIncluded } from '../tree/utils'; import { JSONSchema } from '../types'; import { PathCrumbs } from './PathCrumbs'; import { TopLevelSchemaRow } from './SchemaRow'; -import { hoveredNodeAtom } from './SchemaRow/state'; +import { ExpansionMode, expansionModeAtom, hoveredNodeAtom } from './SchemaRow/state'; export type JsonSchemaProps = Partial & { schema: JSONSchema; @@ -29,11 +28,13 @@ export type JsonSchemaProps = Partial & { maxHeight?: number; parentCrumbs?: string[]; skipTopLevelDescription?: boolean; + showExpandAll?: boolean; }; const JsonSchemaViewerComponent = ({ viewMode = 'standalone', defaultExpandedDepth = 1, + showExpandAll = false, onGoToRef, renderRowAddon, renderExtensionAddon, @@ -48,6 +49,7 @@ const JsonSchemaViewerComponent = ({ () => ({ defaultExpandedDepth, viewMode, + showExpandAll, onGoToRef, renderRowAddon, renderExtensionAddon, @@ -59,6 +61,7 @@ const JsonSchemaViewerComponent = ({ [ defaultExpandedDepth, viewMode, + showExpandAll, onGoToRef, renderRowAddon, renderExtensionAddon, @@ -73,7 +76,12 @@ const JsonSchemaViewerComponent = ({ - + @@ -83,6 +91,7 @@ const JsonSchemaViewerComponent = ({ const JsonSchemaViewerInner = ({ schema, viewMode, + showExpandAll, className, resolveRef, maxRefDepth, @@ -95,6 +104,7 @@ const JsonSchemaViewerInner = ({ JsonSchemaProps, | 'schema' | 'viewMode' + | 'showExpandAll' | 'className' | 'resolveRef' | 'maxRefDepth' @@ -104,11 +114,18 @@ const JsonSchemaViewerInner = ({ | 'parentCrumbs' | 'skipTopLevelDescription' >) => { - const setHoveredNode = useUpdateAtom(hoveredNodeAtom); + const setHoveredNode = useSetAtom(hoveredNodeAtom); + const [expansionMode, setExpansionMode] = useAtom(expansionModeAtom); + const onMouseLeave = React.useCallback(() => { setHoveredNode(null); }, [setHoveredNode]); + const onCollapseExpandAll = React.useCallback(() => { + const newExpansionMode: ExpansionMode = expansionMode === 'expand_all' ? 'collapse_all' : 'expand_all'; + setExpansionMode(newExpansionMode); + }, [expansionMode, setExpansionMode]); + const { jsonSchemaTreeRoot, nodeCount } = React.useMemo(() => { const jsonSchemaTree = new JsonSchemaTree(schema, { mergeAllOf: true, @@ -123,6 +140,7 @@ const JsonSchemaViewerInner = ({ nodeCount++; return true; } + return false; }); jsonSchemaTree.populate(); @@ -144,6 +162,7 @@ const JsonSchemaViewerInner = ({ () => jsonSchemaTreeRoot.children.every(node => !isRegularNode(node) || node.unknown), [jsonSchemaTreeRoot], ); + if (isEmpty) { return ( @@ -160,7 +179,15 @@ const JsonSchemaViewerInner = ({ onMouseLeave={onMouseLeave} style={{ maxHeight }} > - + {showExpandAll ? ( + +   + + + ) : null} + ); diff --git a/src/components/PathCrumbs/index.tsx b/src/components/PathCrumbs/index.tsx index 6930ddf3..218807fc 100644 --- a/src/components/PathCrumbs/index.tsx +++ b/src/components/PathCrumbs/index.tsx @@ -1,15 +1,28 @@ -import { Box, HStack } from '@stoplight/mosaic'; +import { Box, Button, HStack } from '@stoplight/mosaic'; import { useAtom } from 'jotai'; import * as React from 'react'; import { useJSVOptionsContext } from '../../contexts'; +import { ExpansionMode, expansionModeAtom } from '../SchemaRow/state'; import { pathCrumbsAtom, showPathCrumbsAtom } from './state'; -export const PathCrumbs = ({ parentCrumbs = [] }: { parentCrumbs?: string[] }) => { +export const PathCrumbs = ({ + parentCrumbs = [], + showExpandAll = false, +}: { + parentCrumbs?: string[]; + showExpandAll?: boolean; +}) => { const [showPathCrumbs] = useAtom(showPathCrumbsAtom); const [pathCrumbs] = useAtom(pathCrumbsAtom); + const [expansionMode, setExpansionMode] = useAtom(expansionModeAtom); const { disableCrumbs } = useJSVOptionsContext(); + const onCollapseExpandAll = React.useCallback(() => { + const newExpansionMode: ExpansionMode = expansionMode === 'expand_all' ? 'collapse_all' : 'expand_all'; + setExpansionMode(newExpansionMode); + }, [expansionMode, setExpansionMode]); + if (disableCrumbs) { return null; } @@ -39,8 +52,6 @@ export const PathCrumbs = ({ parentCrumbs = [] }: { parentCrumbs?: string[] }) = return ( /} h="md" // so that the crumbs take up no space in the dom, and thus do not push content down when they appear mt={-8} @@ -56,8 +67,31 @@ export const PathCrumbs = ({ parentCrumbs = [] }: { parentCrumbs?: string[] }) = color="light" alignItems="center" > - {parentCrumbElems} - {pathCrumbElems.length && .}>{pathCrumbElems}} + /} + w="full" + h="md" + borderB + fontFamily="mono" + fontSize="sm" + lineHeight="none" + bg="canvas-pure" + px="px" + color="light" + alignItems="center" + justifyContent="between" + > + {parentCrumbElems} + {pathCrumbElems.length && .}>{pathCrumbElems}} + + {showExpandAll ? ( + + + + ) : null} ); }; diff --git a/src/components/SchemaRow/SchemaRow.tsx b/src/components/SchemaRow/SchemaRow.tsx index 4703b2a0..2196f70d 100644 --- a/src/components/SchemaRow/SchemaRow.tsx +++ b/src/components/SchemaRow/SchemaRow.tsx @@ -1,8 +1,7 @@ import { isMirroredNode, isReferenceNode, isRegularNode, SchemaNode } from '@stoplight/json-schema-tree'; import { Box, Flex, NodeAnnotation, Select, SpaceVals, VStack } from '@stoplight/mosaic'; import type { ChangeType } from '@stoplight/types'; -import { Atom } from 'jotai'; -import { useAtomValue, useUpdateAtom } from 'jotai/utils'; +import { Atom, useAtomValue, useSetAtom } from 'jotai'; import last from 'lodash/last.js'; import * as React from 'react'; @@ -15,7 +14,7 @@ import { Caret, Description, getValidationsFromSchema, Types, Validations } from import { ChildStack } from '../shared/ChildStack'; import { Error } from '../shared/Error'; import { Properties, useHasProperties } from '../shared/Properties'; -import { hoveredNodeAtom, isNodeHoveredAtom } from './state'; +import { expansionModeAtom, hoveredNodeAtom, isNodeHoveredAtom } from './state'; import { useChoices } from './useChoices'; export interface SchemaRowProps { @@ -39,7 +38,9 @@ export const SchemaRow: React.FunctionComponent = React.memo( viewMode, } = useJSVOptionsContext(); - const setHoveredNode = useUpdateAtom(hoveredNodeAtom); + const setHoveredNode = useSetAtom(hoveredNodeAtom); + + const expansionMode = useAtomValue(expansionModeAtom); const nodeId = getNodeId(schemaNode, parentNodeId); @@ -49,7 +50,7 @@ export const SchemaRow: React.FunctionComponent = React.memo( const hasChanged = nodeHasChanged?.({ nodeId: originalNodeId, mode }); const [isExpanded, setExpanded] = React.useState( - !isMirroredNode(schemaNode) && nestingLevel <= defaultExpandedDepth, + expansionMode === 'expand_all' ? true : !isMirroredNode(schemaNode) && nestingLevel <= defaultExpandedDepth, ); const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); @@ -67,6 +68,14 @@ export const SchemaRow: React.FunctionComponent = React.memo( const validations = isRegularNode(schemaNode) ? schemaNode.validations : {}; const hasProperties = useHasProperties({ required, deprecated, validations }); + React.useEffect(() => { + if (expansionMode === 'expand_all' && !isExpanded) { + setExpanded(true); + } else if (expansionMode === 'collapse_all' && isExpanded) { + setExpanded(false); + } + }, [isExpanded, expansionMode]); + const [totalVendorExtensions, vendorExtensions] = React.useMemo( () => extractVendorExtensions(schemaNode.fragment), [schemaNode.fragment], diff --git a/src/components/SchemaRow/TopLevelSchemaRow.tsx b/src/components/SchemaRow/TopLevelSchemaRow.tsx index 5f264837..e09212f8 100644 --- a/src/components/SchemaRow/TopLevelSchemaRow.tsx +++ b/src/components/SchemaRow/TopLevelSchemaRow.tsx @@ -1,7 +1,7 @@ import { isPlainObject } from '@stoplight/json'; import { isRegularNode, RegularNode } from '@stoplight/json-schema-tree'; import { Box, Flex, HStack, Icon, Menu, Pressable } from '@stoplight/mosaic'; -import { useUpdateAtom } from 'jotai/utils'; +import { useSetAtom } from 'jotai'; import { isEmpty } from 'lodash'; import * as React from 'react'; @@ -22,7 +22,6 @@ export const TopLevelSchemaRow = ({ skipDescription, }: Pick & { skipDescription?: boolean }) => { const { renderExtensionAddon } = useJSVOptionsContext(); - const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); const childNodes = React.useMemo(() => visibleChildren(selectedChoice.type), [selectedChoice.type]); const nestingLevel = 0; @@ -150,7 +149,7 @@ function ScrollCheck() { const elementRef = React.useRef(null); const isOnScreen = useIsOnScreen(elementRef); - const setShowPathCrumbs = useUpdateAtom(showPathCrumbsAtom); + const setShowPathCrumbs = useSetAtom(showPathCrumbsAtom); React.useEffect(() => { setShowPathCrumbs(!isOnScreen); }, [isOnScreen, setShowPathCrumbs]); diff --git a/src/components/SchemaRow/state.ts b/src/components/SchemaRow/state.ts index 6a389649..14da4ba1 100644 --- a/src/components/SchemaRow/state.ts +++ b/src/components/SchemaRow/state.ts @@ -2,6 +2,9 @@ import { SchemaNode } from '@stoplight/json-schema-tree'; import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; +export type ExpansionMode = 'expand_all' | 'collapse_all' | 'off'; + +export const expansionModeAtom = atom('off'); export const hoveredNodeAtom = atom(null); export const isNodeHoveredAtom = atomFamily((node: SchemaNode) => atom(get => node === get(hoveredNodeAtom))); export const isChildNodeHoveredAtom = atomFamily((parent: SchemaNode) => diff --git a/src/contexts/jsvOptions.tsx b/src/contexts/jsvOptions.tsx index 7a5a8ae7..58df6db9 100644 --- a/src/contexts/jsvOptions.tsx +++ b/src/contexts/jsvOptions.tsx @@ -6,6 +6,7 @@ import { ExtensionAddonRenderer, GoToRefHandler, RowAddonRenderer, ViewMode } fr export type JSVOptions = { defaultExpandedDepth: number; viewMode: ViewMode; + showExpandAll?: boolean; onGoToRef?: GoToRefHandler; renderRowAddon?: RowAddonRenderer; renderExtensionAddon?: ExtensionAddonRenderer; @@ -19,6 +20,7 @@ const JSVOptionsContext = React.createContext({ defaultExpandedDepth: 0, viewMode: 'standalone', hideExamples: false, + showExpandAll: false, }); export const useJSVOptionsContext = () => React.useContext(JSVOptionsContext); From d88201acae6fa86a3f631d1f917233964c7682ef Mon Sep 17 00:00:00 2001 From: Weyert de Boer <7049+weyert@users.noreply.github.com> Date: Fri, 26 Jul 2024 22:01:09 +0100 Subject: [PATCH 2/2] fix: use `maxRefDepth` to limit the Expand All functionality to avoid component getting stuck --- src/__stories__/Default.tsx | 8 ++++++++ src/components/JsonSchemaViewer.tsx | 24 +++++++++++++++++++++--- src/components/SchemaRow/SchemaRow.tsx | 12 +++++++++--- src/contexts/jsvOptions.tsx | 2 ++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/__stories__/Default.tsx b/src/__stories__/Default.tsx index c20e8036..0430a3c5 100644 --- a/src/__stories__/Default.tsx +++ b/src/__stories__/Default.tsx @@ -9,6 +9,7 @@ const defaultSchema = require('../__fixtures__/default-schema.json'); const stressSchema = require('../__fixtures__/stress-schema.json'); const githubIssueSchema = require('../__fixtures__/real-world/github-issue.json'); const arrayOfComplexObjects = require('../__fixtures__/arrays/of-complex-objects.json'); +const allOfComplexSchema = require('../__fixtures__/combiners/allOfs/complex.json'); export default { component: JsonSchemaViewer, @@ -100,3 +101,10 @@ ExpandCollapseAll.args = { schema: githubIssueSchema as JSONSchema4, }; ExpandCollapseAll.storyName = 'Expand/Collapse All'; + +export const CircularExpandCollapseAll = Template.bind({}); +CircularExpandCollapseAll.args = { + showExpandAll: true, + maxRefDepth: 10, + schema: allOfComplexSchema as JSONSchema4, +}; diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 7148cf1d..a6225390 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -1,6 +1,7 @@ import { isRegularNode, RootNode, + SchemaNodeKind, SchemaTree as JsonSchemaTree, SchemaTreeRefDereferenceFn, } from '@stoplight/json-schema-tree'; @@ -11,7 +12,7 @@ import { Provider, useAtom, useSetAtom } from 'jotai'; import * as React from 'react'; import { JSVOptions, JSVOptionsContextProvider } from '../contexts'; -import { shouldNodeBeIncluded } from '../tree/utils'; +import { isNonEmptyParentNode, shouldNodeBeIncluded } from '../tree/utils'; import { JSONSchema } from '../types'; import { PathCrumbs } from './PathCrumbs'; import { TopLevelSchemaRow } from './SchemaRow'; @@ -34,7 +35,8 @@ export type JsonSchemaProps = Partial & { const JsonSchemaViewerComponent = ({ viewMode = 'standalone', defaultExpandedDepth = 1, - showExpandAll = false, + maxRefDepth, + showExpandAll = true, onGoToRef, renderRowAddon, renderExtensionAddon, @@ -48,6 +50,7 @@ const JsonSchemaViewerComponent = ({ const options = React.useMemo( () => ({ defaultExpandedDepth, + maxRefDepth, viewMode, showExpandAll, onGoToRef, @@ -60,6 +63,7 @@ const JsonSchemaViewerComponent = ({ }), [ defaultExpandedDepth, + maxRefDepth, viewMode, showExpandAll, onGoToRef, @@ -163,6 +167,20 @@ const JsonSchemaViewerInner = ({ [jsonSchemaTreeRoot], ); + // Naive check if there are collapsible rows + const hasCollapsibleRows = React.useMemo(() => { + if (jsonSchemaTreeRoot.children.length === 0) { + return false; + } + + if (isNonEmptyParentNode(jsonSchemaTreeRoot.children[0])) { + return jsonSchemaTreeRoot.children[0].children.some(childNode => { + return isRegularNode(childNode) && childNode.primaryType === SchemaNodeKind.Object; + }); + } + return false; + }, [jsonSchemaTreeRoot]); + if (isEmpty) { return ( @@ -179,7 +197,7 @@ const JsonSchemaViewerInner = ({ onMouseLeave={onMouseLeave} style={{ maxHeight }} > - {showExpandAll ? ( + {hasCollapsibleRows && showExpandAll ? (