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: () => (
-
-
-
-
- ),
+ renderRowAddon: ({ nestingLevel }) => {
+ return nestingLevel == 1 ? (
+
+
+
+
+ ) : 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 ? (