From ed3980df3c40a8b7f26ff150c8a81b12fbb4239a Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 23 Jul 2024 11:44:57 +0200 Subject: [PATCH] feat(compass-crud): allows nested elements to also expand in steps (#6009) --- .../document-fields-toggle-group.tsx | 89 ------- .../document-list/document.spec.tsx | 56 ++++- .../src/components/document-list/document.tsx | 65 +++-- .../src/components/document-list/element.tsx | 126 ++++++++-- .../src/components/document-list/index.ts | 2 +- .../document-list/visible-field-toggle.tsx | 115 +++++++++ ...pec.tsx => visible-fields-toggle.spec.tsx} | 26 +- packages/hadron-document/src/document.ts | 27 ++ .../hadron-document/src/element-events.ts | 1 + packages/hadron-document/src/element.ts | 53 +++- packages/hadron-document/src/index.ts | 8 +- .../hadron-document/test/document.test.ts | 101 ++++++++ packages/hadron-document/test/element.test.ts | 234 +++++++++++++++++- 13 files changed, 768 insertions(+), 135 deletions(-) delete mode 100644 packages/compass-components/src/components/document-list/document-fields-toggle-group.tsx create mode 100644 packages/compass-components/src/components/document-list/visible-field-toggle.tsx rename packages/compass-components/src/components/document-list/{document-fields-toggle-group.spec.tsx => visible-fields-toggle.spec.tsx} (78%) diff --git a/packages/compass-components/src/components/document-list/document-fields-toggle-group.tsx b/packages/compass-components/src/components/document-list/document-fields-toggle-group.tsx deleted file mode 100644 index b51ba530577..00000000000 --- a/packages/compass-components/src/components/document-list/document-fields-toggle-group.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { css } from '@leafygreen-ui/emotion'; -import { spacing } from '@leafygreen-ui/tokens'; -import { Button, Icon } from '../leafygreen'; - -const container = css({ - display: 'flex', - gap: spacing[2], - paddingTop: spacing[3], - paddingLeft: spacing[3], - paddingRight: spacing[3], -}); - -const button = css({ - flex: 'none', -}); - -const DocumentFieldsToggleGroup: React.FunctionComponent<{ - showHideButton?: boolean; - currentSize: number; - totalSize: number; - minSize?: number; - step?: number; - onSizeChange(newSize: number): void; -}> = ({ - showHideButton = true, - currentSize, - totalSize, - minSize = 25, - step = 1000, - onSizeChange, -}) => { - const showSizeDiff = useMemo(() => { - return Math.min(totalSize - currentSize, step); - }, [currentSize, step, totalSize]); - - const hideSizeDiff = useMemo(() => { - return Math.max(currentSize - minSize, 0); - }, [currentSize, minSize]); - - const isShowButtonVisible = useMemo(() => { - return showSizeDiff > 0; - }, [showSizeDiff]); - - const isHideButtonVisible = useMemo(() => { - return showHideButton && hideSizeDiff > 0; - }, [hideSizeDiff, showHideButton]); - - const onShowClick = useCallback(() => { - onSizeChange(currentSize + showSizeDiff); - }, [currentSize, onSizeChange, showSizeDiff]); - - const onHideClick = useCallback(() => { - onSizeChange(currentSize - hideSizeDiff); - }, [currentSize, hideSizeDiff, onSizeChange]); - - if (!isShowButtonVisible && !isHideButtonVisible) { - return null; - } - - return ( -
- {isShowButtonVisible && ( - - )} - {isHideButtonVisible && ( - - )} -
- ); -}; - -export default DocumentFieldsToggleGroup; diff --git a/packages/compass-components/src/components/document-list/document.spec.tsx b/packages/compass-components/src/components/document-list/document.spec.tsx index 65222644681..90dcf9f1edf 100644 --- a/packages/compass-components/src/components/document-list/document.spec.tsx +++ b/packages/compass-components/src/components/document-list/document.spec.tsx @@ -1,6 +1,12 @@ import React, { useState } from 'react'; import { expect } from 'chai'; -import { render, cleanup, screen, within } from '@testing-library/react'; +import { + render, + cleanup, + screen, + within, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import HadronDocument from 'hadron-document'; import Document from './document'; @@ -244,4 +250,52 @@ describe('Document', function () { expect(() => screen.getByText('firstName')).to.throw; expect(() => screen.getByText('lastName')).to.throw; }); + + it('should render "Show more" toggle when number of fields are more than allowed visible fields', async function () { + const hadronDoc = new HadronDocument({ + prop1: 'prop1', + prop2: 'prop2', + prop3: 'prop3', + prop4: 'prop4', + }); + hadronDoc.setMaxVisibleElementsCount(2); + render(); + expect(screen.getByText('prop1')).to.exist; + expect(screen.getByText('prop2')).to.exist; + expect(() => screen.getByText('prop3')).to.throw; + expect(() => screen.getByText('prop4')).to.throw; + expect(screen.getByText('Show 2 more fields')).to.exist; + + hadronDoc.setMaxVisibleElementsCount(25); + await waitFor(() => { + expect(screen.getByText('prop3')).to.exist; + expect(screen.getByText('prop4')).to.exist; + }); + }); + + it('should render "Show more" toggle on element when its nested fields are more than allowed visible fields', async function () { + const hadronDoc = new HadronDocument({ + nested: { + prop1: 'prop1', + prop2: 'prop2', + prop3: 'prop3', + prop4: 'prop4', + }, + }); + const [nestedElement] = [...hadronDoc.elements]; + hadronDoc.expand(); + render(); + expect(screen.getByText('nested')).to.exist; + expect(screen.getByText('prop1')).to.exist; + expect(screen.getByText('prop2')).to.exist; + expect(screen.getByText('prop3')).to.exist; + expect(screen.getByText('prop4')).to.exist; + + nestedElement.setMaxVisibleElementsCount(2); + await waitFor(() => { + expect(() => screen.getByText('prop3')).to.throw; + expect(() => screen.getByText('prop4')).to.throw; + }); + expect(screen.getByText('Show 2 more fields in nested')).to.exist; + }); }); diff --git a/packages/compass-components/src/components/document-list/document.tsx b/packages/compass-components/src/components/document-list/document.tsx index 8e0619e8378..d7bef7101be 100644 --- a/packages/compass-components/src/components/document-list/document.tsx +++ b/packages/compass-components/src/components/document-list/document.tsx @@ -4,18 +4,31 @@ import type { default as HadronDocumentType, Element as HadronElementType, } from 'hadron-document'; -import { ElementEvents } from 'hadron-document'; +import { + DEFAULT_VISIBLE_DOCUMENT_ELEMENTS, + DocumentEvents, + ElementEvents, +} from 'hadron-document'; import { AutoFocusContext } from './auto-focus-context'; import { useForceUpdate } from './use-force-update'; -import { HadronElement } from './element'; +import { calculateShowMoreToggleOffset, HadronElement } from './element'; import { usePrevious } from './use-previous'; -import DocumentFieldsToggleGroup from './document-fields-toggle-group'; +import VisibleFieldsToggle from './visible-field-toggle'; import { documentTypography } from './typography'; function useHadronDocument(doc: HadronDocumentType) { const prevDoc = usePrevious(doc); const forceUpdate = useForceUpdate(); + const onVisibleElementsChanged = useCallback( + (document: HadronDocumentType) => { + if (document === doc) { + forceUpdate(); + } + }, + [doc, forceUpdate] + ); + const onDocumentFieldsAddedOrRemoved = useCallback( ( _el: HadronElementType, @@ -39,17 +52,20 @@ function useHadronDocument(doc: HadronDocumentType) { }, [prevDoc, doc, forceUpdate]); useEffect(() => { + doc.on(DocumentEvents.VisibleElementsChanged, onVisibleElementsChanged); doc.on(ElementEvents.Added, onDocumentFieldsAddedOrRemoved); doc.on(ElementEvents.Removed, onDocumentFieldsAddedOrRemoved); return () => { + doc.off(DocumentEvents.VisibleElementsChanged, onVisibleElementsChanged); doc.off(ElementEvents.Added, onDocumentFieldsAddedOrRemoved); doc.off(ElementEvents.Removed, onDocumentFieldsAddedOrRemoved); }; - }, [doc, onDocumentFieldsAddedOrRemoved]); + }, [doc, onDocumentFieldsAddedOrRemoved, onVisibleElementsChanged]); return { elements: [...doc.elements], + visibleElements: doc.getVisibleElements(), }; } @@ -61,8 +77,6 @@ const hadronDocument = css({ counterReset: 'line-number', }); -const INITIAL_FIELD_LIMIT = 25; - // TODO: This element should implement treegrid aria role to be accessible // https://www.w3.org/TR/wai-aria-practices/examples/treegrid/treegrid-1.html // https://jira.mongodb.org/browse/COMPASS-5614 @@ -72,12 +86,7 @@ const HadronDocument: React.FunctionComponent<{ editing?: boolean; onEditStart?: () => void; }> = ({ value: document, editable = false, editing = false, onEditStart }) => { - const { elements } = useHadronDocument(document); - const [visibleFieldsCount, setVisibleFieldsCount] = - useState(INITIAL_FIELD_LIMIT); - const visibleElements = useMemo(() => { - return elements.filter(Boolean).slice(0, visibleFieldsCount); - }, [elements, visibleFieldsCount]); + const { elements, visibleElements } = useHadronDocument(document); const [autoFocus, setAutoFocus] = useState<{ id: string; type: 'key' | 'value' | 'type'; @@ -89,6 +98,25 @@ const HadronDocument: React.FunctionComponent<{ } }, [editing]); + const handleVisibleFieldsChanged = useCallback( + (totalVisibleFields: number) => { + document.setMaxVisibleElementsCount(totalVisibleFields); + }, + [document] + ); + + // To render the "Show more" toggle for the document we need to calculate a + // proper offset so that it aligns with the expand icon of top level fields + const showMoreToggleOffset = useMemo( + () => + calculateShowMoreToggleOffset({ + editable, + level: 0, + alignWithNestedExpandIcon: false, + }), + [editable] + ); + return (
- + onSizeChange={handleVisibleFieldsChanged} + style={{ + paddingLeft: showMoreToggleOffset, + }} + >
); }; diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index 86ccae0e2b1..de3e51e54c2 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -11,7 +11,11 @@ import type { Element as HadronElementType, Editor as EditorType, } from 'hadron-document'; -import { ElementEvents, ElementEditor } from 'hadron-document'; +import { + ElementEvents, + ElementEditor, + DEFAULT_VISIBLE_ELEMENTS, +} from 'hadron-document'; import BSONValue from '../bson-value'; import { spacing } from '@leafygreen-ui/tokens'; import { KeyEditor, ValueEditor, TypeEditor } from './element-editors'; @@ -23,6 +27,7 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; import { Icon } from '../leafygreen'; import { useDarkMode } from '../../hooks/use-theme'; +import VisibleFieldsToggle from './visible-field-toggle'; function getEditorByType(type: HadronElementType['type']) { switch (type) { @@ -103,6 +108,7 @@ function useHadronElement(el: HadronElementType) { el.on(ElementEvents.Removed, onElementAddedOrRemoved); el.on(ElementEvents.Expanded, onElementChanged); el.on(ElementEvents.Collapsed, onElementChanged); + el.on(ElementEvents.VisibleElementsChanged, onElementChanged); return () => { el.off(ElementEvents.Converted, onElementChanged); @@ -113,6 +119,7 @@ function useHadronElement(el: HadronElementType) { el.off(ElementEvents.Removed, onElementAddedOrRemoved); el.off(ElementEvents.Expanded, onElementChanged); el.off(ElementEvents.Collapsed, onElementChanged); + el.off(ElementEvents.VisibleElementsChanged, onElementChanged); }; }, [el, onElementChanged, onElementAddedOrRemoved]); @@ -162,6 +169,7 @@ function useHadronElement(el: HadronElementType) { remove: el.isNotActionable() ? null : el.remove.bind(el), expandable: Boolean(el.elements), children: el.elements ? [...el.elements] : [], + visibleChildren: el.getVisibleElements(), level: el.level, parentType: el.parent?.currentType, removed: el.isRemoved(), @@ -355,6 +363,38 @@ const elementKeyDarkMode = css({ color: palette.gray.light2, }); +const calculateElementSpacerWidth = (editable: boolean, level: number) => { + return (editable ? spacing[200] : 0) + spacing[400] * level; +}; + +export const calculateShowMoreToggleOffset = ({ + editable, + level, + alignWithNestedExpandIcon, +}: { + editable: boolean; + level: number; + alignWithNestedExpandIcon: boolean; +}) => { + // the base padding that we have on all elements rendered in the document + const BASE_PADDING_LEFT = spacing[200]; + const OFFSET_WHEN_EDITABLE = editable + ? // space taken by element actions + spacing[400] + + // space and margin taken by line number element + spacing[400] + + spacing[100] + + // element spacer width that we render + calculateElementSpacerWidth(editable, level) + : 0; + const EXPAND_ICON_SIZE = spacing[400]; + return ( + BASE_PADDING_LEFT + + OFFSET_WHEN_EDITABLE + + (alignWithNestedExpandIcon ? EXPAND_ICON_SIZE : 0) + ); +}; + export const HadronElement: React.FunctionComponent<{ value: HadronElementType; editable: boolean; @@ -381,6 +421,7 @@ export const HadronElement: React.FunctionComponent<{ remove, expandable, children, + visibleChildren, level, parentType, removed, @@ -400,9 +441,26 @@ export const HadronElement: React.FunctionComponent<{ const charCount = String(lineNumberSize).length; return charCount > 2 ? `${charCount}.5ch` : spacing[3]; } - return spacing[3]; + return spacing[400]; }, [lineNumberSize, editingEnabled]); + const elementSpacerWidth = useMemo( + () => calculateElementSpacerWidth(editable, level), + [editable, level] + ); + + // To render the "Show more" toggle for the nested expandable elements we need + // to calculate a proper offset so that it aligns with the nesting level + const nestedElementsVisibilityToggleOffset = useMemo( + () => + calculateShowMoreToggleOffset({ + editable, + level, + alignWithNestedExpandIcon: true, + }), + [editable, level] + ); + const isValid = key.valid && value.valid; const shouldShowActions = editingEnabled; @@ -436,6 +494,14 @@ export const HadronElement: React.FunctionComponent<{ const lineNumberInvalid = darkMode ? lineNumberInvalidDarkMode : lineNumberInvalidLightMode; + + const handleVisibleElementsChanged = useCallback( + (totalVisibleFields: number) => { + element.setMaxVisibleElementsCount(totalVisibleFields); + }, + [element] + ); + return ( <>
)} -
+
{/* spacer for nested documents */}
@@ -636,21 +699,42 @@ export const HadronElement: React.FunctionComponent<{
)}
- {expandable && - expanded && - children.map((el, idx) => { - return ( - - ); - })} + {expandable && expanded && ( + <> + {visibleChildren.map((el, idx) => { + return ( + + ); + })} + + + )} ); }; diff --git a/packages/compass-components/src/components/document-list/index.ts b/packages/compass-components/src/components/document-list/index.ts index cd11603f6b7..38b67e68498 100644 --- a/packages/compass-components/src/components/document-list/index.ts +++ b/packages/compass-components/src/components/document-list/index.ts @@ -1,4 +1,4 @@ export { default as DocumentActionsGroup } from './document-actions-group'; -export { default as DocumentFieldsToggleGroup } from './document-fields-toggle-group'; +export { default as VisibleFieldsToggle } from './visible-field-toggle'; export { default as Document } from './document'; export { default as DocumentEditActionsFooter } from './document-edit-actions-footer'; diff --git a/packages/compass-components/src/components/document-list/visible-field-toggle.tsx b/packages/compass-components/src/components/document-list/visible-field-toggle.tsx new file mode 100644 index 00000000000..a0ffcdccb49 --- /dev/null +++ b/packages/compass-components/src/components/document-list/visible-field-toggle.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useMemo } from 'react'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; +import { Icon, Link } from '../leafygreen'; +import { documentTypography } from './typography'; + +const container = css({ + display: 'flex', + gap: spacing[200], + paddingTop: spacing[200], +}); + +const linkButtonStyles = css({ + border: 'none', + background: 'none', + padding: 0, + fontFamily: documentTypography.fontFamily, + fontSize: `${documentTypography.fontSize}px`, + lineHeight: `${documentTypography.lineHeight}px`, + '& > span': { + display: 'inline-flex', + alignItems: 'center', + gap: spacing[100], + }, +}); + +const VisibleFieldsToggle: React.FunctionComponent<{ + showHideButton?: boolean; + buttonClassName?: string; + parentFieldName?: string; + currentSize: number; + totalSize: number; + minSize?: number; + step?: number; + style?: React.CSSProperties; + onSizeChange(newSize: number): void; +}> = ({ + showHideButton = true, + buttonClassName, + parentFieldName, + currentSize, + totalSize, + minSize = 25, + step = 1000, + style, + onSizeChange, +}) => { + const showSizeDiff = useMemo(() => { + return Math.min(totalSize - currentSize, step); + }, [currentSize, step, totalSize]); + + const hideSizeDiff = useMemo(() => { + return Math.max(currentSize - minSize, 0); + }, [currentSize, minSize]); + + const isShowButtonVisible = useMemo(() => { + return showSizeDiff > 0; + }, [showSizeDiff]); + + const isHideButtonVisible = useMemo(() => { + return showHideButton && hideSizeDiff > 0; + }, [hideSizeDiff, showHideButton]); + + const onShowClick = useCallback(() => { + onSizeChange(currentSize + showSizeDiff); + }, [currentSize, onSizeChange, showSizeDiff]); + + const onHideClick = useCallback(() => { + onSizeChange(currentSize - hideSizeDiff); + }, [currentSize, hideSizeDiff, onSizeChange]); + + if (!isShowButtonVisible && !isHideButtonVisible) { + return null; + } + + const showButtonText = `Show ${showSizeDiff} more ${ + showSizeDiff === 1 ? 'field' : 'fields' + }${parentFieldName ? ` in ${parentFieldName}` : ''}`; + const hideButtonText = `Hide ${hideSizeDiff} ${ + hideSizeDiff === 1 ? 'field' : 'fields' + }${parentFieldName ? ` in ${parentFieldName}` : ''}`; + + return ( +
+ {isShowButtonVisible && ( + + + {showButtonText} + + )} + {isHideButtonVisible && ( + + + {hideButtonText} + + )} +
+ ); +}; + +export default VisibleFieldsToggle; diff --git a/packages/compass-components/src/components/document-list/document-fields-toggle-group.spec.tsx b/packages/compass-components/src/components/document-list/visible-fields-toggle.spec.tsx similarity index 78% rename from packages/compass-components/src/components/document-list/document-fields-toggle-group.spec.tsx rename to packages/compass-components/src/components/document-list/visible-fields-toggle.spec.tsx index 8538a9a5ee1..c66451ac6ab 100644 --- a/packages/compass-components/src/components/document-list/document-fields-toggle-group.spec.tsx +++ b/packages/compass-components/src/components/document-list/visible-fields-toggle.spec.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { expect } from 'chai'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import DocumentFieldsToggleGroup from './document-fields-toggle-group'; +import VisibleFieldsToggle from './visible-field-toggle'; function TestComponent({ initialSize = 10, @@ -10,20 +10,29 @@ function TestComponent({ totalSize = Infinity, minSize = 10, step = 10, + parentFieldName, +}: { + initialSize?: number; + showHideButton?: boolean; + totalSize?: number; + minSize?: number; + step?: number; + parentFieldName?: string; }) { const [size, setSize] = useState(initialSize); return (
Showing {size} items
- + parentFieldName={parentFieldName} + >
); } @@ -41,6 +50,17 @@ describe('DocumentFieldsToggleGroup', function () { expect(screen.getByText('Hide 10 fields')).to.exist; }); + it('should include parentFieldName in the label when provided', function () { + render( + + ); + expect(screen.getByText('Hide 10 fields in reviews')).to.exist; + }); + it('should show more items when "Show items" is clicked', function () { render(); expect(screen.getByText('Showing 10 items')).to.exist; diff --git a/packages/hadron-document/src/document.ts b/packages/hadron-document/src/document.ts index 54842157350..844e7b2b6b4 100644 --- a/packages/hadron-document/src/document.ts +++ b/packages/hadron-document/src/document.ts @@ -19,6 +19,7 @@ export const Events = { Cancel: 'Document::Cancel', Expanded: 'Document::Expanded', Collapsed: 'Document::Collapsed', + VisibleElementsChanged: 'Document::VisibleElementsChanged', }; /** @@ -26,6 +27,8 @@ export const Events = { */ const ID = '_id'; +export const DEFAULT_VISIBLE_ELEMENTS = 25; + /** * Represents a document. */ @@ -39,6 +42,7 @@ export class Document extends EventEmitter { currentType: 'Document'; size: number | null = null; expanded = false; + maxVisibleElementsCount = DEFAULT_VISIBLE_ELEMENTS; /** * Send cancel event. @@ -262,6 +266,7 @@ export class Document extends EventEmitter { insertBeginning(key: string | number, value: BSONValue): Element { const newElement = this.elements.insertBeginning(key, value); newElement._bubbleUp(ElementEvents.Added, newElement, this); + this.emit(Events.VisibleElementsChanged, this); return newElement; } @@ -276,6 +281,7 @@ export class Document extends EventEmitter { insertEnd(key: string | number, value: BSONValue): Element { const newElement = this.elements.insertEnd(key, value); newElement._bubbleUp(ElementEvents.Added, newElement, this); + this.emit(Events.VisibleElementsChanged, this); return newElement; } @@ -295,6 +301,7 @@ export class Document extends EventEmitter { ): Element | undefined { const newElement = this.elements.insertAfter(element, key, value); newElement?._bubbleUp(ElementEvents.Added, newElement, this); + this.emit(Events.VisibleElementsChanged, this); return newElement; } @@ -399,6 +406,7 @@ export class Document extends EventEmitter { element.expand(true); } this.emit(Events.Expanded); + this.emit(Events.VisibleElementsChanged, this); } /** @@ -410,6 +418,25 @@ export class Document extends EventEmitter { element.collapse(); } this.emit(Events.Collapsed); + this.emit(Events.VisibleElementsChanged, this); + } + + getVisibleElements() { + return [...this.elements].slice(0, this.maxVisibleElementsCount); + } + + setMaxVisibleElementsCount(newCount: number) { + this.maxVisibleElementsCount = newCount; + this.emit(Events.VisibleElementsChanged, this); + } + + getTotalVisibleElementsCount() { + const visibleElements = this.getVisibleElements(); + return visibleElements.reduce((totalVisibleChildElements, element) => { + return ( + totalVisibleChildElements + 1 + element.getTotalVisibleElementsCount() + ); + }, 0); } } diff --git a/packages/hadron-document/src/element-events.ts b/packages/hadron-document/src/element-events.ts index 7584a6b132f..0bba0d3d342 100644 --- a/packages/hadron-document/src/element-events.ts +++ b/packages/hadron-document/src/element-events.ts @@ -11,4 +11,5 @@ export default { Valid: 'Element::Valid', Expanded: 'Element::Expanded', Collapsed: 'Element::Collapsed', + VisibleElementsChanged: 'Element::VisibleElementsChanged', } as const; diff --git a/packages/hadron-document/src/element.ts b/packages/hadron-document/src/element.ts index b3bc37c4fd7..9898ed76bc6 100644 --- a/packages/hadron-document/src/element.ts +++ b/packages/hadron-document/src/element.ts @@ -13,7 +13,7 @@ import type { TypeCastTypes } from 'hadron-type-checker'; import type { ObjectId } from 'bson'; import type { BSONArray, BSONObject, BSONValue } from './utils'; import { getDefaultValueForType } from './utils'; -import { ElementEvents } from '.'; +import { DocumentEvents, ElementEvents } from '.'; export const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS'; export { Events }; @@ -46,6 +46,7 @@ const UNEDITABLE_TYPES = [ 'DBRef', ]; +export const DEFAULT_VISIBLE_ELEMENTS = 25; export function isValueExpandable( value: BSONValue ): value is BSONObject | BSONArray { @@ -78,6 +79,7 @@ export class Element extends EventEmitter { invalidTypeMessage?: string; decrypted: boolean; expanded = false; + maxVisibleElementsCount = DEFAULT_VISIBLE_ELEMENTS; /** * Cancel any modifications to the element. @@ -311,6 +313,7 @@ export class Element extends EventEmitter { } const newElement = this.elements.insertAfter(element, key, value); newElement!._bubbleUp(Events.Added, newElement, this); + this.emitVisibleElementsChanged(); return newElement!; } @@ -328,6 +331,7 @@ export class Element extends EventEmitter { } const newElement = this.elements.insertEnd(key, value, true); this._bubbleUp(Events.Added, newElement); + this.emitVisibleElementsChanged(); return newElement; } @@ -704,6 +708,7 @@ export class Element extends EventEmitter { this.removed = true; if (this.parent) { this._bubbleUp(Events.Removed, this, this.parent); + this.emitVisibleElementsChanged(this.parent); } } @@ -714,6 +719,9 @@ export class Element extends EventEmitter { if (this.isAdded()) { this.parent?.elements?.remove(this); this._bubbleUp(Events.Removed, this, this.parent); + if (this.parent) { + this.emitVisibleElementsChanged(this.parent); + } delete (this as any).parent; } else { if (this.originalExpandableValue) { @@ -752,6 +760,7 @@ export class Element extends EventEmitter { } } this.emit(ElementEvents.Expanded, this); + this.emitVisibleElementsChanged(); } /** @@ -769,6 +778,7 @@ export class Element extends EventEmitter { } } this.emit(ElementEvents.Collapsed, this); + this.emitVisibleElementsChanged(); } /** @@ -840,6 +850,47 @@ export class Element extends EventEmitter { } } + getVisibleElements() { + if (!this.elements || !this.expanded) { + return []; + } + return [...this.elements].slice(0, this.maxVisibleElementsCount); + } + + setMaxVisibleElementsCount(newCount: number) { + if (!this._isExpandable()) { + return; + } + this.maxVisibleElementsCount = newCount; + this.emitVisibleElementsChanged(); + } + + getTotalVisibleElementsCount(): number { + if (!this.elements || !this.expanded) { + return 0; + } + return this.getVisibleElements().reduce( + (totalVisibleChildElements, element) => { + return ( + totalVisibleChildElements + 1 + element.getTotalVisibleElementsCount() + ); + }, + 0 + ); + } + + private emitVisibleElementsChanged(targetElement: Element | Document = this) { + if (targetElement.isRoot()) { + targetElement.emit(DocumentEvents.VisibleElementsChanged, targetElement); + } else if (targetElement.expanded) { + targetElement._bubbleUp( + Events.VisibleElementsChanged, + targetElement, + targetElement.getRoot() + ); + } + } + /** * @deprecated Use ElementEvents import instead */ diff --git a/packages/hadron-document/src/index.ts b/packages/hadron-document/src/index.ts index f90392e4039..73c7c9bd48e 100644 --- a/packages/hadron-document/src/index.ts +++ b/packages/hadron-document/src/index.ts @@ -1,7 +1,11 @@ -import Document, { Events as DocumentEvents } from './document'; +import Document, { + Events as DocumentEvents, + DEFAULT_VISIBLE_ELEMENTS as DEFAULT_VISIBLE_DOCUMENT_ELEMENTS, +} from './document'; import Element, { Events as ElementEvents, isInternalFieldPath, + DEFAULT_VISIBLE_ELEMENTS, } from './element'; import ElementEditor from './editor'; import type { Editor } from './editor'; @@ -12,8 +16,10 @@ export type { Editor }; export { Document, DocumentEvents, + DEFAULT_VISIBLE_DOCUMENT_ELEMENTS, Element, ElementEvents, + DEFAULT_VISIBLE_ELEMENTS, ElementEditor, isInternalFieldPath, getDefaultValueForType, diff --git a/packages/hadron-document/test/document.test.ts b/packages/hadron-document/test/document.test.ts index ac7b7260f62..f2686b67c98 100644 --- a/packages/hadron-document/test/document.test.ts +++ b/packages/hadron-document/test/document.test.ts @@ -2419,4 +2419,105 @@ describe('Document', function () { expect(collapsedSpy).to.be.calledOnce; }); }); + + describe('#getVisibleElements', function () { + const doc = { + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }; + + it("should return total elements if the elements are less than document's visible element count", function () { + expect(new Document(doc).getVisibleElements()).to.have.lengthOf(2); + }); + + it("should return sliced list of nested elements if nested elements are more than element's visible element count", function () { + const document = new Document(doc); + document.setMaxVisibleElementsCount(1); + const visibleElements = document.getVisibleElements(); + expect(visibleElements).to.have.lengthOf(1); + expect(visibleElements.map((element) => element.value)).to.deep.equal([ + 'a1', + ]); + + document.setMaxVisibleElementsCount(10); + expect(document.getVisibleElements()).to.have.lengthOf(2); + }); + }); + + describe('#setVisibleElementsCount', function () { + it('should update the visible count and emit an event', function () { + const spy = Sinon.spy(); + const document = new Document({ + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }); + document.on(DocumentEvents.VisibleElementsChanged, spy); + document.setMaxVisibleElementsCount(10); + expect(document.maxVisibleElementsCount).to.equal(10); + expect(spy).to.be.calledWithExactly(document); + }); + }); + + describe('#getTotalVisibleElementsCount', function () { + it('should return the count of top level fields only if none of the fields are expanded', function () { + const document = new Document({ + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }); + expect(document.getTotalVisibleElementsCount()).to.equal(2); + }); + it('should do a recursive tree traversal for providing the total visible elements count', function () { + const document = new Document({ + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }); + // we need to expand the element all the way to its children to make it + // visible + document.expand(); + expect(document.getTotalVisibleElementsCount()).to.equal(4); + }); + context( + 'and total elements are less than the allowed visible elements', + function () { + it('should return the count for number of elements', function () { + const document = new Document({ + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }); + expect(document.getTotalVisibleElementsCount()).to.equal(2); + }); + } + ); + context( + 'and total child elements are more than the allowed visible elements', + function () { + it('should return the count for allowed visible elements', function () { + const document = new Document({ + a1: 'a1', + b1: { + b11: 'b1.1', + b12: 'b1.2', + }, + }); + document.setMaxVisibleElementsCount(1); + expect(document.getTotalVisibleElementsCount()).to.equal(1); + }); + } + ); + }); }); diff --git a/packages/hadron-document/test/element.test.ts b/packages/hadron-document/test/element.test.ts index 39dfbc5d29c..8f733eb686c 100644 --- a/packages/hadron-document/test/element.test.ts +++ b/packages/hadron-document/test/element.test.ts @@ -12,7 +12,12 @@ import { } from 'bson'; import { expect } from 'chai'; import { Document, Element, ElementEvents } from '../src/'; -import { DATE_FORMAT, isValueExpandable } from '../src/element'; +import type { ElementList } from '../src/element'; +import { + DATE_FORMAT, + DEFAULT_VISIBLE_ELEMENTS, + isValueExpandable, +} from '../src/element'; import moment from 'moment'; import Sinon from 'sinon'; @@ -2780,6 +2785,233 @@ describe('Element', function () { }); }); + describe('#getVisibleElements', function () { + context('when element is not expandable', function () { + it('should return an empty list', function () { + expect(new Element('name', 'A').getVisibleElements()).to.have.lengthOf( + 0 + ); + expect(new Element('count', 1).getVisibleElements()).to.have.lengthOf( + 0 + ); + expect( + new Element('_id', new ObjectId()).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('_id', new Binary()).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Code('')).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new MaxKey()).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new MinKey()).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Timestamp({ t: 1, i: 1 })).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Int32(32)).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Long(32)).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Double(0.2)).getVisibleElements() + ).to.have.lengthOf(0); + expect( + new Element('x', new Decimal128('0.2')).getVisibleElements() + ).to.have.lengthOf(0); + }); + }); + + context('when element is expandable', function () { + it("should return total nested elements if nested elements are less than element's visible element count", function () { + const listElement = new Element('list', [1, 2, 3]); + expect(listElement.getVisibleElements()).to.have.lengthOf(0); // because it is not expanded + listElement.expand(); + expect(listElement.getVisibleElements()).to.have.lengthOf(3); + + const obElement = new Element('ob', { prop1: '1', prop2: '2' }); + expect(obElement.getVisibleElements()).to.have.lengthOf(0); // because it is not expanded + obElement.expand(); + expect(obElement.getVisibleElements()).to.have.lengthOf(2); + }); + + it("should return sliced list of nested elements if nested elements are more than element's visible element count", function () { + const listElement = new Element('list', [1, 2, 3]); + listElement.expand(); + listElement.setMaxVisibleElementsCount(1); + const listVisibleElements = listElement.getVisibleElements(); + expect(listVisibleElements).to.have.lengthOf(1); + expect( + listVisibleElements.map((element) => element.value) + ).to.deep.equal([{ value: 1 }]); + listElement.setMaxVisibleElementsCount(25); + expect(listElement.getVisibleElements()).to.have.lengthOf(3); + + const obElement = new Element('ob', { + prop1: '1', + prop2: '2', + prop3: '3', + }); + obElement.expand(); + obElement.setMaxVisibleElementsCount(1); + const obVisibleElement = obElement.getVisibleElements(); + expect(obVisibleElement).to.have.lengthOf(1); + expect(obVisibleElement.map((element) => element.value)).to.deep.equal([ + '1', + ]); + obElement.setMaxVisibleElementsCount(25); + expect(obElement.getVisibleElements()).to.have.lengthOf(3); + }); + }); + }); + + describe('#setVisibleElementsCount', function () { + context('when element is not expandable', function () { + it('should not do anything', function () { + const element = new Element('name', 'string'); + const spy = Sinon.spy(); + element.on(ElementEvents.VisibleElementsChanged, spy); + element.setMaxVisibleElementsCount(10); + expect(element.maxVisibleElementsCount).to.equal( + DEFAULT_VISIBLE_ELEMENTS + ); + expect(spy).to.not.be.called; + }); + }); + + context('when element is expandable', function () { + it('should update the visible count and bubble up the event', function () { + const spy = Sinon.spy(); + const rootElement = new Element('nestedOb', { + address: { + zip: '111111', + }, + }); + const [addressElement] = [...(rootElement.elements as ElementList)]; + rootElement.on(ElementEvents.VisibleElementsChanged, spy); + addressElement.setMaxVisibleElementsCount(10); + expect(addressElement.maxVisibleElementsCount).to.equal(10); + expect(spy).to.not.be.called; // not called because the element was not expanded and hence no visible changes + + rootElement.expand(true); + addressElement.setMaxVisibleElementsCount(11); + expect(addressElement.maxVisibleElementsCount).to.equal(11); + expect(spy).to.be.calledWithExactly(addressElement, rootElement); + }); + }); + }); + + describe('#getTotalVisibleElementsCount', function () { + context('if the element is not expandable', function () { + it('should return 0', function () { + expect( + new Element('name', 'A').getTotalVisibleElementsCount() + ).to.equal(0); + expect(new Element('count', 1).getTotalVisibleElementsCount()).to.equal( + 0 + ); + expect( + new Element('_id', new ObjectId()).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('_id', new Binary()).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new Code('')).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new MaxKey()).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new MinKey()).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element( + 'x', + new Timestamp({ t: 1, i: 1 }) + ).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new Int32(32)).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new Long(32)).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new Double(0.2)).getTotalVisibleElementsCount() + ).to.equal(0); + expect( + new Element('x', new Decimal128('0.2')).getTotalVisibleElementsCount() + ).to.equal(0); + }); + }); + context('if element is expandable', function () { + it('should still return 0 if the item is not expanded', function () { + const nestedObElement = new Element('nestedOb', { + prop1: { + prop2: { + prop3: { + prop4: { + prop5: 'Yup', + }, + }, + }, + }, + }); + expect(nestedObElement.getTotalVisibleElementsCount()).to.equal(0); + }); + it('should do a recursive tree traversal for providing the total visible elements count', function () { + const nestedObElement = new Element('nestedOb', { + prop1: { + prop2: { + prop3: { + prop4: { + prop5: 'Yup', + }, + }, + }, + }, + }); + // we need to expand the element all the way to its children to make it + // visible + nestedObElement.expand(true); + expect(nestedObElement.getTotalVisibleElementsCount()).to.equal(5); + }); + context( + 'and total child elements are less than the allowed visible elements', + function () { + it('should return the count for number of child elements', function () { + const nestedObElement = new Element('nestedOb', { + prop1: 'x', + prop2: 'y', + }); + nestedObElement.expand(); + expect(nestedObElement.getTotalVisibleElementsCount()).to.equal(2); + }); + } + ); + context( + 'and total child elements are more than the allowed visible elements', + function () { + it('should return the count for allowed visible elements', function () { + const nestedObElement = new Element('nestedOb', { + prop1: 'x', + prop2: 'y', + }); + nestedObElement.expand(); + nestedObElement.setMaxVisibleElementsCount(1); + expect(nestedObElement.getTotalVisibleElementsCount()).to.equal(1); + }); + } + ); + }); + }); + context( 'when expanding an element that has been added with a changed type', function () {