diff --git a/packages/compass-components/src/components/document-list/document.tsx b/packages/compass-components/src/components/document-list/document.tsx index 2e2d9cb540c..705af18acd6 100644 --- a/packages/compass-components/src/components/document-list/document.tsx +++ b/packages/compass-components/src/components/document-list/document.tsx @@ -10,7 +10,7 @@ import { ElementEvents, } from 'hadron-document'; import { AutoFocusContext } from './auto-focus-context'; -import { useForceUpdate } from './use-force-update'; +import { useForceUpdate } from '../../hooks/use-force-update'; import { calculateShowMoreToggleOffset, HadronElement } from './element'; import { usePrevious } from './use-previous'; import VisibleFieldsToggle from './visible-field-toggle'; diff --git a/packages/compass-components/src/components/document-list/element.tsx b/packages/compass-components/src/components/document-list/element.tsx index b79c96f4015..49cf8e277d5 100644 --- a/packages/compass-components/src/components/document-list/element.tsx +++ b/packages/compass-components/src/components/document-list/element.tsx @@ -21,7 +21,7 @@ import { spacing } from '@leafygreen-ui/tokens'; import { KeyEditor, ValueEditor, TypeEditor } from './element-editors'; import { EditActions, AddFieldActions } from './element-actions'; import { useAutoFocusContext } from './auto-focus-context'; -import { useForceUpdate } from './use-force-update'; +import { useForceUpdate } from '../../hooks/use-force-update'; import { usePrevious } from './use-previous'; import { css, cx } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; diff --git a/packages/compass-components/src/components/document-list/use-force-update.tsx b/packages/compass-components/src/hooks/use-force-update.tsx similarity index 100% rename from packages/compass-components/src/components/document-list/use-force-update.tsx rename to packages/compass-components/src/hooks/use-force-update.tsx diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index d577dd2bf13..2fb943dda18 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -213,3 +213,4 @@ export { export { SelectList } from './components/select-list'; export { ParagraphSkeleton } from '@leafygreen-ui/skeleton-loader'; export { InsightsChip } from './components/insights-chip'; +export { useForceUpdate } from './hooks/use-force-update'; diff --git a/packages/compass-crud/src/components/table-view/cell-renderer.tsx b/packages/compass-crud/src/components/table-view/cell-renderer.tsx index 86f7051852d..8526fd9c426 100644 --- a/packages/compass-crud/src/components/table-view/cell-renderer.tsx +++ b/packages/compass-crud/src/components/table-view/cell-renderer.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useMemo, useCallback, useEffect, useState } from 'react'; import { BSONValue, css, @@ -8,9 +7,9 @@ import { LeafyGreenProvider, spacing, withDarkMode, + useForceUpdate, } from '@mongodb-js/compass-components'; -import { Element } from 'hadron-document'; -import type { ICellRendererReactComp } from 'ag-grid-react'; +import { type Document, Element } from 'hadron-document'; import type { ICellRendererParams } from 'ag-grid-community'; import type { GridActions, TableHeaderType } from '../../stores/grid-store'; import type { CrudActions } from '../../stores/crud-store'; @@ -61,6 +60,11 @@ const UNEDITABLE = 'is-uneditable'; */ const INVALID = 'is-invalid'; +/** + * The valid constant. + */ +const VALID = 'valid'; + /** * The deleted constant. */ @@ -81,182 +85,111 @@ const decrypdedIconStyles = css({ display: 'flex', }); -export type CellRendererProps = Omit & { - context: GridContext; - parentType: TableHeaderType; - elementAdded: GridActions['elementAdded']; - elementRemoved: GridActions['elementRemoved']; - elementTypeChanged: GridActions['elementTypeChanged']; - drillDown: CrudActions['drillDown']; - tz: string; - darkMode?: boolean; -}; - -/** - * The custom cell renderer that renders a cell in the table view. - */ -class CellRenderer - extends React.Component - implements ICellRendererReactComp -{ - element: Element; - isEmpty: boolean; - isDeleted: boolean; - editable: boolean; - - constructor(props: CellRendererProps) { - super(props); - - this.isEmpty = props.value === undefined || props.value === null; - this.isDeleted = false; - this.element = props.value; - - /* Can't get the editable() function from here, so have to reevaluate */ - this.editable = true; - if (props.context.path.length > 0 && props.column.getColId() !== '$_id') { - const parent = props.node.data.hadronDocument.getChild( - props.context.path - ); - if ( - !parent || - (props.parentType && parent.currentType !== props.parentType) - ) { - this.editable = false; - } else if (parent.currentType === 'Array') { - let maxKey = 0; - if (parent.elements.lastElement) { - maxKey = +parent.elements.lastElement.currentKey + 1; - } - if (+props.column.getColId() > maxKey) { - this.editable = false; - } - } - } +const getElementLength = ( + element: Element | undefined | null +): number | undefined => { + if (!element) { + return undefined; } - componentDidMount() { - if (!this.isEmpty) { - this.subscribeElementEvents(); - } + if (element.currentType === 'Object') { + return Object.keys(element.generateObject() as object).length; } - - componentWillUnmount() { - if (!this.isEmpty) { - this.unsubscribeElementEvents(); - } - } - - subscribeElementEvents() { - this.element.on(Element.Events.Added, this.handleElementEvent); - this.element.on(Element.Events.Converted, this.handleElementEvent); - this.element.on(Element.Events.Edited, this.handleElementEvent); - this.element.on(Element.Events.Reverted, this.handleElementEvent); + if (element.currentType === 'Array' && element.elements) { + return element.elements.size; } +}; - unsubscribeElementEvents() { - this.element.removeListener(Element.Events.Added, this.handleElementEvent); - this.element.removeListener( - Element.Events.Converted, - this.handleElementEvent - ); - this.element.removeListener(Element.Events.Edited, this.handleElementEvent); - this.element.removeListener( - Element.Events.Reverted, - this.handleElementEvent - ); - } +interface CellContentProps { + element: Element | undefined | null; + cellState: + | typeof UNEDITABLE + | typeof EMPTY + | typeof INVALID + | typeof DELETED + | typeof ADDED + | typeof EDITED + | typeof VALID; + onUndo: (event: React.MouseEvent) => void; + onExpand: (event: React.MouseEvent) => void; +} - handleElementEvent = () => { - this.forceUpdate(); - }; - - handleUndo = (event: React.MouseEvent) => { - event.stopPropagation(); - const oid = this.props.node.data.hadronDocument.getStringId(); - if (this.element.isAdded()) { - this.isDeleted = true; - const isArray = - !this.element.parent?.isRoot() && - this.element.parent?.currentType === 'Array'; - this.props.elementRemoved(String(this.element.currentKey), oid, isArray); - } else if (this.element.isRemoved()) { - this.props.elementAdded( - String(this.element.currentKey), - this.element.currentType, - oid - ); - } else { - this.props.elementTypeChanged( - String(this.element.currentKey), - this.element.type, - oid - ); +const CellContent: React.FC = ({ + element, + cellState, + onUndo, + onExpand, +}) => { + const forceUpdate = useForceUpdate(); + const isEmpty = element === undefined || element === null; + const handleElementEvent = useCallback(() => { + forceUpdate(); + }, []); + + // Subscribe to element events + useEffect(() => { + if (!isEmpty && element) { + element.on(Element.Events.Added, handleElementEvent); + element.on(Element.Events.Converted, handleElementEvent); + element.on(Element.Events.Edited, handleElementEvent); + element.on(Element.Events.Reverted, handleElementEvent); + + return () => { + element.removeListener(Element.Events.Added, handleElementEvent); + element.removeListener(Element.Events.Converted, handleElementEvent); + element.removeListener(Element.Events.Edited, handleElementEvent); + element.removeListener(Element.Events.Reverted, handleElementEvent); + }; } - this.element.revert(); - }; + }, [isEmpty, element, handleElementEvent]); - handleDrillDown(event: React.MouseEvent) { - event.stopPropagation(); - this.props.drillDown(this.props.node.data.hadronDocument, this.element); - } + const elementLength = getElementLength(element); - handleClicked() { - if (this.props.node.data.state === 'editing') { - this.props.api.startEditingCell({ - rowIndex: this.props.node.rowIndex, - colKey: this.props.column.getColId(), - }); + const renderContent = useCallback(() => { + if (cellState === EMPTY || !element) { + return 'No field'; } - } - refresh() { - return true; - } - - renderInvalidCell() { - let valueClass = `${VALUE_CLASS}-is-${this.element.currentType.toLowerCase()}`; - valueClass = `${valueClass} ${INVALID_VALUE}`; + if (cellState === UNEDITABLE) { + return ''; + } - /* Return internal div because invalid cells should only hightlight text? */ + if (cellState === DELETED) { + return 'Deleted field'; + } - return
{this.element.currentValue}
; - } + if (cellState === INVALID) { + let valueClass = `${VALUE_CLASS}-is-${element.currentType.toLowerCase()}`; + valueClass = `${valueClass} ${INVALID_VALUE}`; - getLength(): number | undefined { - if (this.element.currentType === 'Object') { - return Object.keys(this.element.generateObject() as object).length; - } - if (this.element.currentType === 'Array') { - return this.element.elements!.size; + return
{element.currentValue}
; } - } - renderValidCell() { let className = VALUE_BASE; - let element: string | JSX.Element = ''; - if (this.element.isAdded()) { - className = `${className} ${VALUE_BASE}-${ADDED}`; - } else if (this.element.isEdited()) { - className = `${className} ${VALUE_BASE}-${EDITED}`; + let elementContent: string | JSX.Element = ''; + if (cellState === ADDED || cellState === EDITED) { + className = `${className} ${VALUE_BASE}-${cellState}`; } - if (this.element.currentType === 'Object') { - element = `{} ${this.getLength() as number} fields`; - } else if (this.element.currentType === 'Array') { - element = `[] ${this.getLength() as number} elements`; + const isArrayOrObject = + element.currentType === 'Array' || element.currentType === 'Object'; + + if (elementLength !== undefined && isArrayOrObject) { + if (element.currentType === 'Object') { + elementContent = `{} ${elementLength} fields`; + } else if (element.currentType === 'Array') { + elementContent = `[] ${elementLength} elements`; + } } else { - element = ( - + elementContent = ( + ); } return (
- {this.props.value.decrypted && ( + {element.decrypted && ( )} - {element} + {elementContent}
); - } - - renderUndo(canUndo: boolean, canExpand: boolean) { - let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; - if (canUndo && canExpand) { - undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + element, + element?.currentType, + element?.currentValue, + elementLength, + cellState, + ]); + + const canUndo = + cellState === ADDED || + cellState === EDITED || + cellState === INVALID || + cellState === DELETED; + + const canExpand = + (cellState === VALID || cellState === ADDED || cellState === EDITED) && + (element?.currentType === 'Object' || element?.currentType === 'Array'); + + return ( + <> + {canUndo && } + {canExpand && } + {renderContent()} + + ); +}; - if (!canUndo) { - return null; - } - return ( - - - - ); - } +export type CellRendererProps = Omit & { + context: GridContext; + parentType: TableHeaderType; + elementAdded: GridActions['elementAdded']; + elementRemoved: GridActions['elementRemoved']; + elementTypeChanged: GridActions['elementTypeChanged']; + drillDown: CrudActions['drillDown']; + tz: string; + darkMode?: boolean; +}; - renderExpand(canExpand: boolean) { - if (!canExpand) { - return null; +/** + * The custom cell renderer that renders a cell in the table view. + */ +const CellRenderer: React.FC = ({ + value, + context, + column, + node, + parentType, + elementAdded, + elementRemoved, + elementTypeChanged, + drillDown, + api, + darkMode, +}) => { + const element = value as Element | undefined | null; + + const isEmpty = element === undefined || element === null; + const [isDeleted, setIsDeleted] = useState(false); + + const isEditable = useMemo(() => { + /* Can't get the editable() function from here, so have to reevaluate */ + let editable = true; + if (context.path.length > 0 && column.getColId() !== '$_id') { + const parent = node.data.hadronDocument.getChild(context.path); + if (!parent || (parentType && parent.currentType !== parentType)) { + editable = false; + } else if (parent.currentType === 'Array') { + let maxKey = 0; + if (parent.elements.lastElement) { + maxKey = +parent.elements.lastElement.currentKey + 1; + } + if (+column.getColId() > maxKey) { + editable = false; + } + } } - return ( - - - - - - ); + return editable; + }, [context.path, column, node.data.hadronDocument, parentType]); + + // Determine cell state + let cellState: + | typeof UNEDITABLE + | typeof EMPTY + | typeof INVALID + | typeof DELETED + | typeof ADDED + | typeof EDITED + | typeof VALID; + + if (!isEditable) { + cellState = UNEDITABLE; + } else if (isEmpty || isDeleted) { + cellState = EMPTY; + } else if (!element.isCurrentTypeValid()) { + cellState = INVALID; + } else if (element.isRemoved()) { + cellState = DELETED; + } else if (element.isAdded()) { + cellState = ADDED; + } else if (element.isModified()) { + cellState = EDITED; + } else { + cellState = VALID; } - render() { - let element; - let className = BEM_BASE; - let canUndo = false; - let canExpand = false; - - if (!this.editable) { - element = ''; - className = `${className}-${UNEDITABLE}`; - } else if (this.isEmpty || this.isDeleted) { - element = 'No field'; - className = `${className}-${EMPTY}`; - } else if (!this.element.isCurrentTypeValid()) { - element = this.renderInvalidCell(); - className = `${className}-${INVALID}`; - canUndo = true; - } else if (this.element.isRemoved()) { - element = 'Deleted field'; - className = `${className}-${DELETED}`; - canUndo = true; - } else { - element = this.renderValidCell(); - if (this.element.isAdded()) { - className = `${className}-${ADDED}`; - canUndo = true; - } else if (this.element.isModified()) { - className = `${className}-${EDITED}`; - canUndo = true; + const handleUndo = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!element) { + return; + } + const oid: string = node.data.hadronDocument.getStringId(); + if (cellState === ADDED) { + setIsDeleted(true); + const isArray = + !element.parent?.isRoot() && element.parent?.currentType === 'Array'; + elementRemoved(String(element.currentKey), oid, isArray); + } else if (cellState === DELETED) { + elementAdded(String(element.currentKey), element.currentType, oid); + } else { + elementTypeChanged(String(element.currentKey), element.type, oid); + } + element.revert(); + }, + [ + element, + node.data.hadronDocument, + elementRemoved, + elementAdded, + elementTypeChanged, + ] + ); + + const handleDrillDown = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!element) { + return; } - canExpand = - this.element.currentType === 'Object' || - this.element.currentType === 'Array'; + drillDown(node.data.hadronDocument as Document, element); + }, + [drillDown, node.data.hadronDocument, element] + ); + + const handleClicked = useCallback(() => { + if (node.data.state === 'editing') { + api.startEditingCell({ + rowIndex: node.rowIndex, + colKey: column.getColId(), + }); } + }, [node, api, column]); + + return ( + // `ag-grid` renders this component outside of the context chain + // so we re-supply the dark mode theme here. + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} +
+ +
+
+ ); +}; - return ( - // `ag-grid` renders this component outside of the context chain - // so we re-supply the dark mode theme here. - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus*/} -
- {this.renderUndo(canUndo, canExpand)} - {this.renderExpand(canExpand)} - {element} -
-
- ); +export default withDarkMode(CellRenderer); + +interface CellUndoButtonProps { + alignLeft: boolean; + onClick: (event: React.MouseEvent) => void; +} + +const CellUndoButton: React.FC = ({ + alignLeft, + onClick, +}) => { + let undoButtonClass = `${BUTTON_CLASS} ${BUTTON_CLASS}-undo`; + if (alignLeft) { + undoButtonClass = `${undoButtonClass} ${BUTTON_CLASS}-left`; } - static propTypes = { - api: PropTypes.any, - value: PropTypes.any, - node: PropTypes.any, - column: PropTypes.any, - context: PropTypes.any, - parentType: PropTypes.any.isRequired, - elementAdded: PropTypes.func.isRequired, - elementRemoved: PropTypes.func.isRequired, - elementTypeChanged: PropTypes.func.isRequired, - drillDown: PropTypes.func.isRequired, - tz: PropTypes.string.isRequired, - darkMode: PropTypes.bool, - }; - - static displayName = 'CellRenderer'; + return ( + + + + ); +}; + +interface CellExpandButtonProps { + onClick: (event: React.MouseEvent) => void; } -export default withDarkMode(CellRenderer); +const CellExpandButton: React.FC = ({ onClick }) => { + return ( + + + + ); +};