diff --git a/packages/compass-components/src/hooks/use-throttled-props.tsx b/packages/compass-components/src/hooks/use-throttled-props.tsx index 4dab1e44797..32b05f58ce6 100644 --- a/packages/compass-components/src/hooks/use-throttled-props.tsx +++ b/packages/compass-components/src/hooks/use-throttled-props.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; const DEFAULT_REFRESH_RATE_MS = 250; -export const useThrottledProps = >( +export const useThrottledProps = ( props: T, refreshRate: number = DEFAULT_REFRESH_RATE_MS ): T => { diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 420e9aec119..6baf8fd443a 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -242,6 +242,7 @@ export { export type { EdgeProps, NodeProps, + DiagramProps, DiagramInstance, NodeField, NodeGlyph, diff --git a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx index 4f3b14ba1a6..e4182196cb4 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx @@ -16,6 +16,7 @@ import { Tooltip, Breadcrumbs, type BreadcrumbItem, + useHotkeys, } from '@mongodb-js/compass-components'; import AddCollection from './icons/add-collection'; import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; @@ -86,6 +87,19 @@ export const DiagramEditorToolbar: React.FunctionComponent<{ [diagramName, openDataModelingWorkspace] ); + // TODO(COMPASS-9976): Integrate with application menu + // macOS: Cmd+Shift+Z = Redo, Cmd+Z = Undo + // Windows/Linux: Ctrl+Z = Undo, Ctrl+Y = Redo + useHotkeys('mod+z', onUndoClick, { enabled: step === 'EDITING' }, [ + onUndoClick, + ]); + useHotkeys('mod+shift+z', onRedoClick, { enabled: step === 'EDITING' }, [ + onRedoClick, + ]); + useHotkeys('mod+y', onRedoClick, { enabled: step === 'EDITING' }, [ + onRedoClick, + ]); + if (step !== 'EDITING') { return null; } diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index af12bcd04ea..0d187f8905e 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -19,8 +19,15 @@ import { createNewRelationship, addCollection, selectField, + deleteCollection, + deleteRelationship, + removeField, } from '../store/diagram'; -import type { EdgeProps, NodeProps } from '@mongodb-js/compass-components'; +import type { + EdgeProps, + NodeProps, + DiagramProps, +} from '@mongodb-js/compass-components'; import { Banner, CancelLoader, @@ -35,6 +42,7 @@ import { rafraf, Diagram, useDiagram, + useHotkeys, } from '@mongodb-js/compass-components'; import { cancelAnalysis, retryAnalysis } from '../store/analysis-process'; import type { FieldPath, StaticModel } from '../services/data-model-storage'; @@ -136,6 +144,9 @@ const DiagramContent: React.FunctionComponent<{ onRelationshipSelect: (rId: string) => void; onFieldSelect: (namespace: string, fieldPath: FieldPath) => void; onDiagramBackgroundClicked: () => void; + onDeleteCollection: (ns: string) => void; + onDeleteRelationship: (rId: string) => void; + onDeleteField: (ns: string, fieldPath: FieldPath) => void; selectedItems: SelectedItems; onCreateNewRelationship: ({ localNamespace, @@ -162,6 +173,9 @@ const DiagramContent: React.FunctionComponent<{ onDiagramBackgroundClicked, onCreateNewRelationship, onRelationshipDrawn, + onDeleteCollection, + onDeleteRelationship, + onDeleteField, selectedItems, DiagramComponent = Diagram, }) => { @@ -272,7 +286,7 @@ const DiagramContent: React.FunctionComponent<{ ); const onNodeClick = useCallback( - (_evt: React.MouseEvent, node: NodeProps) => { + (_evt: React.MouseEvent | null, node: NodeProps) => { if (node.type !== 'collection') { return; } @@ -283,7 +297,7 @@ const DiagramContent: React.FunctionComponent<{ ); const onEdgeClick = useCallback( - (_evt: React.MouseEvent, edge: EdgeProps) => { + (_evt: React.MouseEvent | null, edge: EdgeProps) => { onRelationshipSelect(edge.id); openDrawer(DATA_MODELING_DRAWER_ID); }, @@ -333,21 +347,47 @@ const DiagramContent: React.FunctionComponent<{ [onAddFieldToObjectField] ); - const diagramProps = useMemo( - () => ({ - isDarkMode, - title: diagramLabel, - edges, - nodes, - onAddFieldToNodeClick: onClickAddFieldToCollection, - onAddFieldToObjectFieldClick: onClickAddFieldToObjectField, - onNodeClick, - onPaneClick, - onEdgeClick, - onFieldClick, - onNodeDragStop, - onConnect, - }), + const deleteItem = useCallback(() => { + switch (selectedItems?.type) { + case 'collection': + onDeleteCollection(selectedItems.id); + break; + case 'relationship': + onDeleteRelationship(selectedItems.id); + break; + case 'field': + onDeleteField(selectedItems.namespace, selectedItems.fieldPath); + break; + default: + break; + } + }, [selectedItems, onDeleteCollection, onDeleteRelationship, onDeleteField]); + useHotkeys('Backspace', deleteItem, [deleteItem]); + useHotkeys('Delete', deleteItem, [deleteItem]); + useHotkeys( + 'Escape', + () => { + onDiagramBackgroundClicked(); + }, + [onDiagramBackgroundClicked] + ); + + const diagramProps: DiagramProps = useMemo( + () => + ({ + isDarkMode, + title: diagramLabel, + edges, + nodes, + onAddFieldToNodeClick: onClickAddFieldToCollection, + onAddFieldToObjectFieldClick: onClickAddFieldToObjectField, + onNodeClick, + onPaneClick, + onEdgeClick, + onFieldClick, + onNodeDragStop, + onConnect, + } satisfies DiagramProps), [ isDarkMode, diagramLabel, @@ -423,6 +463,9 @@ const ConnectedDiagramContent = connect( onFieldSelect: selectField, onDiagramBackgroundClicked: selectBackground, onCreateNewRelationship: createNewRelationship, + onDeleteCollection: deleteCollection, + onDeleteRelationship: deleteRelationship, + onDeleteField: removeField, } )(DiagramContent); diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx index 13d6fa16228..17ec6c08ea4 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx @@ -45,6 +45,13 @@ const updateInputWithBlur = (label: string, text: string) => { userEvent.click(document.body); }; +const updateInputWithEnter = (label: string, text: string) => { + const input = screen.getByLabelText(label); + userEvent.clear(input); + if (text.length) userEvent.type(input, text); + userEvent.type(input, '{enter}'); +}; + async function comboboxSelectItem( label: string, value: string, @@ -785,5 +792,28 @@ describe('DiagramEditorSidePanel', function () { expect(notModifiedCollection).to.exist; }); + + it('should handle collection name and notes editing using enter', async function () { + const result = renderDrawer(); + result.plugin.store.dispatch(addCollection()); + + await waitForDrawerToOpen(); + + updateInputWithEnter('Name', 'pineapple'); + + userEvent.click(screen.getByRole('textbox', { name: 'Notes' })); + userEvent.type( + screen.getByRole('textbox', { name: 'Notes' }), + 'Note about the relationship{shift>}{enter}{/shift}next line' + ); + + const collection = selectCurrentModelFromState( + result.plugin.store.getState() + ).collections.find((n) => n.ns === 'flights.pineapple'); + expect(collection).to.exist; + expect(screen.getByRole('textbox', { name: 'Notes' })).to.have.value( + 'Note about the relationship\nnext line' + ); + }); }); }); diff --git a/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx b/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx index 46d8e0aff19..d3278f2a802 100644 --- a/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx +++ b/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx @@ -7,6 +7,7 @@ export function useChangeOnBlur( value: string; onChange: React.ChangeEventHandler; onBlur: React.FocusEventHandler; + onKeyDown: React.KeyboardEventHandler; } { const [_value, setValue] = useState(value); useLayoutEffect(() => { @@ -22,5 +23,10 @@ export function useChangeOnBlur( onBlur: () => { onChange(_value); }, + onKeyDown: (evt) => { + if (evt.key === 'Enter' && !evt.shiftKey) { + (evt.target as HTMLInputElement | HTMLTextAreaElement).blur?.(); + } + }, }; } diff --git a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts index bff9dd760f6..346fbbdf077 100644 --- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts +++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts @@ -22,7 +22,7 @@ import toNS from 'mongodb-ns'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; -import type { ChainablePromiseElement } from 'webdriverio'; +import { Key, type ChainablePromiseElement } from 'webdriverio'; type Node = { id: string; @@ -838,6 +838,16 @@ describe('Data Modeling tab', function () { // This is to ensure that the initial edit of the collection name wasn't a separate edit await browser.clickVisible(Selectors.DataModelUndoButton); await getDiagramNodes(browser, 2); + + // Repeatedly Redo + Undo through keyboard shortcuts + await browser.keys([Key.Control, 'y']); + await getDiagramNodes(browser, 3); + await browser.keys([Key.Control, 'z']); + await getDiagramNodes(browser, 2); + await browser.keys([Key.Command, Key.Shift, 'z']); + await getDiagramNodes(browser, 3); + await browser.keys([Key.Command, 'z']); + await getDiagramNodes(browser, 2); }); }); });