From 6c45725709805b8294a3c3da50f0ae5d271450fb Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 23 Jan 2026 18:43:17 +0100 Subject: [PATCH 1/5] fix: improve diagram rename --- .../src/components/data-modeling.tsx | 7 +- .../src/components/diagram-card.spec.tsx | 47 --- .../src/components/diagram-card.tsx | 141 --------- .../src/components/diagram-list-toolbar.tsx | 94 ------ .../drawer/collection-drawer-content.tsx | 18 +- .../drawer/diagram-editor-side-panel.tsx | 3 +- .../diagram-overview-drawer-content.tsx | 18 +- .../drawer/relationships-section.tsx | 6 +- .../src/components/drawer/util.ts | 34 --- .../src/components/import-diagram-button.tsx | 45 --- .../components/saved-diagrams-list.spec.tsx | 237 ---------------- .../src/components/saved-diagrams-list.tsx | 268 ------------------ .../src/store/diagram.ts | 29 +- .../src/store/reducer.ts | 8 + packages/compass-data-modeling/src/utils.ts | 23 -- .../compass-data-modeling/src/utils/utils.ts | 22 ++ 16 files changed, 64 insertions(+), 936 deletions(-) delete mode 100644 packages/compass-data-modeling/src/components/diagram-card.spec.tsx delete mode 100644 packages/compass-data-modeling/src/components/diagram-card.tsx delete mode 100644 packages/compass-data-modeling/src/components/diagram-list-toolbar.tsx delete mode 100644 packages/compass-data-modeling/src/components/drawer/util.ts delete mode 100644 packages/compass-data-modeling/src/components/import-diagram-button.tsx delete mode 100644 packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx delete mode 100644 packages/compass-data-modeling/src/components/saved-diagrams-list.tsx delete mode 100644 packages/compass-data-modeling/src/utils.ts diff --git a/packages/compass-data-modeling/src/components/data-modeling.tsx b/packages/compass-data-modeling/src/components/data-modeling.tsx index ff24ef0b414..26aeb247c8d 100644 --- a/packages/compass-data-modeling/src/components/data-modeling.tsx +++ b/packages/compass-data-modeling/src/components/data-modeling.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import DiagramEditor from './diagram-editor'; -import SavedDiagramsList from './saved-diagrams-list'; +import SavedDiagramsList from './list/saved-diagrams-list'; import NewDiagramFormModal from './new-diagram/new-diagram-modal'; import type { DataModelingState } from '../store/reducer'; import { Button, css, DiagramProvider } from '@mongodb-js/compass-components'; @@ -9,10 +9,12 @@ import DiagramEditorSidePanel from './drawer/diagram-editor-side-panel'; import ReselectCollectionsModal from './reselect-collections-modal'; import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; import { useDataModelSavedItems } from '../provider'; +import RenameDiagramModal from './list/rename-modal'; type DataModelingProps = { showList: boolean; currentDiagramId?: string; + renameDiagramId?: string; }; const deletedDiagramContainerStyles = css({ @@ -43,6 +45,7 @@ const DeletedDiagramInfo: React.FunctionComponent = () => { const DataModeling: React.FunctionComponent = ({ showList, currentDiagramId, + renameDiagramId, }) => { const dataModels = useDataModelSavedItems(); const showDeletedInfo = useMemo(() => { @@ -65,6 +68,7 @@ const DataModeling: React.FunctionComponent = ({ )} + ); }; @@ -73,5 +77,6 @@ export default connect((state: DataModelingState) => { return { showList: state.step === 'NO_DIAGRAM_SELECTED', currentDiagramId: state.diagram?.id, + renameDiagramId: state.renameDiagramModal?.diagramId, }; })(DataModeling); diff --git a/packages/compass-data-modeling/src/components/diagram-card.spec.tsx b/packages/compass-data-modeling/src/components/diagram-card.spec.tsx deleted file mode 100644 index 1b39b21b929..00000000000 --- a/packages/compass-data-modeling/src/components/diagram-card.spec.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { expect } from 'chai'; -import { render, screen } from '@mongodb-js/testing-library-compass'; -import { DiagramCard } from './diagram-card'; -import type { Edit } from '../services/data-model-storage'; - -describe('DiagramCard', () => { - const props = { - diagram: { - id: 'test-diagram', - connectionId: 'test-connection', - database: 'someDatabase', - name: 'Test Diagram', - createdAt: '2021-10-01T00:00:00.000Z', - updatedAt: '2023-10-03T00:00:00.000', - edits: [ - { - id: 'edit-id', - timestamp: '2022-10-01T00:00:00.000Z', - type: 'SetModel', - model: { - collections: [ - { - ns: 'db.collection', - indexes: [], - displayPosition: [0, 0], - shardKey: {}, - fieldData: { bsonType: 'object' }, - }, - ], - relationships: [], - }, - }, - ] as [Edit], - }, - onOpen: () => {}, - onDelete: () => {}, - onRename: () => {}, - }; - - it('renders name, database, last edited', () => { - render(); - expect(screen.getByText('Test Diagram')).to.be.visible; - expect(screen.getByText('someDatabase')).to.be.visible; - expect(screen.getByText('Last modified: October 3, 2023')).to.be.visible; - }); -}); diff --git a/packages/compass-data-modeling/src/components/diagram-card.tsx b/packages/compass-data-modeling/src/components/diagram-card.tsx deleted file mode 100644 index dda15de5fd7..00000000000 --- a/packages/compass-data-modeling/src/components/diagram-card.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { - Card, - css, - cx, - Icon, - ItemActionMenu, - palette, - spacing, - Subtitle, - useDarkMode, - useFormattedDate, -} from '@mongodb-js/compass-components'; -import type { MongoDBDataModelDescription } from '../services/data-model-storage'; -import React from 'react'; - -// Same as saved-queries-aggregations -export const CARD_WIDTH = spacing[1600] * 4; -export const CARD_HEIGHT = 180; - -const diagramCardStyles = css({ - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', -}); - -const cardContentStyles = css({ - display: 'flex', - flexDirection: 'column', - height: '100%', - justifyContent: 'flex-end', - gap: spacing[300], -}); - -const namespaceNameStyles = css({ - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', -}); - -const namespaceIconStyles = css({ - flexShrink: 0, -}); - -const lastModifiedLabel = css({ - fontStyle: 'italic', -}); - -const namespaceStyles = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[200], -}); - -const cardHeaderStyles = css({ - display: 'flex', - gap: spacing[200], - alignItems: 'flex-start', -}); -const cardTitle = css({ - fontWeight: 'bold', - height: spacing[600] * 2, - marginBottom: spacing[400], - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', -}); - -const cardTitleDark = css({ - color: palette.green.light2, -}); -const cardTitleLight = css({ - color: palette.green.dark2, -}); - -export function DiagramCard({ - diagram, - onOpen, - onRename, - onDelete, -}: { - diagram: MongoDBDataModelDescription; - onOpen: (diagram: MongoDBDataModelDescription) => void; - onRename: (id: string) => void; - onDelete: (id: string) => void; -}) { - const darkmode = useDarkMode(); - const formattedDate = useFormattedDate(new Date(diagram.updatedAt).getTime()); - return ( - onOpen(diagram)} - data-testid="saved-diagram-card" - data-diagram-name={diagram.name} - title={diagram.name} - > -
- - {diagram.name} - - { - switch (action) { - case 'rename': - onRename(diagram.id); - break; - case 'delete': - onDelete(diagram.id); - break; - default: - break; - } - }} - > -
-
-
- - {diagram.database} -
-
- Last modified: {formattedDate} -
-
-
- ); -} diff --git a/packages/compass-data-modeling/src/components/diagram-list-toolbar.tsx b/packages/compass-data-modeling/src/components/diagram-list-toolbar.tsx deleted file mode 100644 index db353a4a599..00000000000 --- a/packages/compass-data-modeling/src/components/diagram-list-toolbar.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useContext } from 'react'; -import { - Button, - css, - cx, - Icon, - palette, - SearchInput, - spacing, - Subtitle, - useDarkMode, -} from '@mongodb-js/compass-components'; -import { DiagramListContext } from './saved-diagrams-list'; -import { ImportDiagramButton } from './import-diagram-button'; - -const containerStyles = css({ - padding: spacing[400], - display: 'grid', - gridTemplateAreas: ` - 'title createDiagram' - 'searchInput sortControls' - `, - columnGap: spacing[800], - rowGap: spacing[200], - gridTemplateColumns: '5fr', -}); - -const titleStyles = css({ - gridArea: 'title', -}); -const diagramActionsStyles = css({ - gridArea: 'createDiagram', - display: 'flex', - justifyContent: 'flex-end', - gap: spacing[200], -}); -const searchInputStyles = css({ - gridArea: 'searchInput', -}); -const sortControlsStyles = css({ - gridArea: 'sortControls', - display: 'flex', - justifyContent: 'flex-end', -}); - -const toolbarTitleLightStyles = css({ color: palette.gray.dark1 }); -const toolbarTitleDarkStyles = css({ color: palette.gray.light1 }); - -export const DiagramListToolbar = () => { - const { - onSearchDiagrams: onSearch, - onCreateDiagram, - sortControls, - searchTerm, - onImportDiagram, - } = useContext(DiagramListContext); - const darkMode = useDarkMode(); - - return ( -
- - Open an existing diagram: - -
- } - size="small" - onImportDiagram={onImportDiagram} - /> - -
- onSearch(e.target.value)} - /> -
{sortControls}
-
- ); -}; diff --git a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx index 22172ad2642..ea27bed5fe5 100644 --- a/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx @@ -22,7 +22,7 @@ import { import { useChangeOnBlur } from './use-change-on-blur'; import { RelationshipsSection } from './relationships-section'; import { getNamespaceRelationships } from '../../utils/utils'; -import { getIsNewNameValid } from './util'; +import { useNewNameValidation } from '../../utils/use-new-name-validation'; type CollectionDrawerContentProps = { namespace: string; @@ -78,16 +78,12 @@ const CollectionDrawerContent: React.FunctionComponent< const { isValid: isCollectionNameValid, errorMessage: collectionNameEditErrorMessage, - } = useMemo( - () => - getIsNewNameValid({ - newName: collectionName, - existingNames: namespaces.map((ns) => toNS(ns).collection), - currentName: toNS(namespace).collection, - entity: 'Collection', - }), - [collectionName, namespaces, namespace] - ); + } = useNewNameValidation({ + newName: collectionName, + existingNames: namespaces.map((ns) => toNS(ns).collection), + currentName: toNS(namespace).collection, + entity: 'Collection', + }); const noteInputProps = useChangeOnBlur(note, (newNote) => { onNoteChange(namespace, newNote); diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx index 7f430895558..1aa91e09d81 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.tsx @@ -17,11 +17,10 @@ import { selectCurrentModelFromState, type SelectedItems, } from '../../store/diagram'; -import { getDefaultRelationshipName } from '../../utils'; import FieldDrawerContent from './field-drawer-content'; import type { FieldPath } from '../../services/data-model-storage'; import { getFieldFromSchema } from '../../utils/schema-traversal'; -import { isIdField } from '../../utils/utils'; +import { getDefaultRelationshipName, isIdField } from '../../utils/utils'; import DiagramOverviewDrawerContent from './diagram-overview-drawer-content'; export const DATA_MODELING_DRAWER_ID = 'data-modeling-drawer'; diff --git a/packages/compass-data-modeling/src/components/drawer/diagram-overview-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/diagram-overview-drawer-content.tsx index bee781321b7..403938ddeaa 100644 --- a/packages/compass-data-modeling/src/components/drawer/diagram-overview-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/diagram-overview-drawer-content.tsx @@ -17,9 +17,9 @@ import { DMFormFieldContainer, } from './drawer-section-components'; import { useChangeOnBlur } from './use-change-on-blur'; -import { getIsNewNameValid } from './util'; import { useConnectionInfoForId } from '@mongodb-js/compass-connections/provider'; import { useDataModelSavedItems } from '../../provider'; +import { useNewNameValidation } from '../../utils/use-new-name-validation'; const infoContainerStyles = css({ marginTop: spacing[400], @@ -115,16 +115,12 @@ const DiagramOverviewDrawerContent: React.FunctionComponent< const { isValid: isDiagramNameValid, errorMessage: diagramNameEditErrorMessage, - } = useMemo( - () => - getIsNewNameValid({ - newName: diagramName, - existingNames: diagramNames, - currentName: _diagramName, - entity: 'Diagram', - }), - [diagramName, _diagramName, diagramNames] - ); + } = useNewNameValidation({ + newName: diagramName, + existingNames: diagramNames, + currentName: _diagramName, + entity: 'Diagram', + }); return ( <> diff --git a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx index 5917cae2aeb..96cb8cd87b4 100644 --- a/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx +++ b/packages/compass-data-modeling/src/components/drawer/relationships-section.tsx @@ -13,8 +13,10 @@ import { useDarkMode, } from '@mongodb-js/compass-components'; import type { Relationship } from '../../services/data-model-storage'; -import { getDefaultRelationshipName } from '../../utils'; -import { isRelationshipValid } from '../../utils/utils'; +import { + getDefaultRelationshipName, + isRelationshipValid, +} from '../../utils/utils'; const titleBtnStyles = css({ marginLeft: 'auto', diff --git a/packages/compass-data-modeling/src/components/drawer/util.ts b/packages/compass-data-modeling/src/components/drawer/util.ts deleted file mode 100644 index ea8864e104b..00000000000 --- a/packages/compass-data-modeling/src/components/drawer/util.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function getIsNewNameValid({ - newName, - existingNames, - currentName, - entity, -}: { - newName: string; - existingNames: string[]; - currentName: string; - entity: string; -}): { - isValid: boolean; - errorMessage?: string; -} { - if (newName.trim().length === 0) { - return { - isValid: false, - errorMessage: `${entity} name cannot be empty.`, - }; - } - - const existingNamesWithoutCurrent = existingNames.filter( - (name) => name !== currentName - ); - - const isDuplicate = existingNamesWithoutCurrent.some( - (name) => name.trim() === newName.trim() - ); - - return { - isValid: !isDuplicate, - errorMessage: isDuplicate ? `${entity} name must be unique.` : undefined, - }; -} diff --git a/packages/compass-data-modeling/src/components/import-diagram-button.tsx b/packages/compass-data-modeling/src/components/import-diagram-button.tsx deleted file mode 100644 index 1041b19bcf6..00000000000 --- a/packages/compass-data-modeling/src/components/import-diagram-button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { - Button, - type ButtonProps, - FileSelector, - Tooltip, -} from '@mongodb-js/compass-components'; - -type ImportDiagramButtonProps = Omit & { - onImportDiagram: (file: File) => void; -}; - -export const ImportDiagramButton = ({ - onImportDiagram, - ...buttonProps -}: ImportDiagramButtonProps) => { - return ( - - { - if (files.length === 0) { - return; - } - onImportDiagram(files[0]); - }} - trigger={({ onClick }) => ( - - )} - /> - - } - > - Only MDM files exported from Compass or Atlas Data Explorer can be - imported. - - ); -}; diff --git a/packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx b/packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx deleted file mode 100644 index 79c625c8634..00000000000 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React from 'react'; -import { expect } from 'chai'; -import { - screen, - userEvent, - waitFor, -} from '@mongodb-js/testing-library-compass'; -import SavedDiagramsList from './saved-diagrams-list'; -import { renderWithStore } from '../../test/setup-store'; -import type { DataModelingStore } from '../../test/setup-store'; -import { DataModelStorageServiceProvider } from '../provider'; -import type { MongoDBDataModelDescription } from '../services/data-model-storage'; - -const storageItems: MongoDBDataModelDescription[] = [ - { - id: '1', - name: 'One', - createdAt: '2023-10-01T00:00:00.000Z', - updatedAt: '2023-10-03T00:00:00.000Z', - edits: [ - { - id: 'edit-id-1', - timestamp: '2023-10-02T00:00:00.000Z', - type: 'SetModel', - model: { - collections: [ - { - ns: 'db1.collection1', - indexes: [], - displayPosition: [1, 1], - shardKey: {}, - fieldData: { bsonType: 'object' }, - }, - ], - relationships: [], - }, - }, - ], - connectionId: null, - database: 'db1', - }, - { - id: '2', - name: 'Two', - createdAt: '2023-10-02T00:00:00.000Z', - updatedAt: '2023-10-04T00:00:00.000Z', - edits: [ - { - id: 'edit-id-2', - timestamp: '2023-10-01T00:00:00.000Z', - type: 'SetModel', - model: { - collections: [ - { - ns: 'db2.collection2', - indexes: [], - displayPosition: [2, 2], - shardKey: {}, - fieldData: { bsonType: 'object' }, - }, - ], - relationships: [], - }, - }, - ], - connectionId: null, - database: 'db2', - }, - { - id: '3', - name: 'Three', - createdAt: '2023-10-01T00:00:00.000Z', - updatedAt: '2023-10-05T00:00:00.000Z', - edits: [ - { - id: 'edit-id-3', - timestamp: '2023-10-01T00:00:00.000Z', - type: 'SetModel', - model: { - collections: [ - { - ns: 'db3.collection3', - indexes: [], - displayPosition: [3, 3], - shardKey: {}, - fieldData: { bsonType: 'object' }, - }, - ], - relationships: [], - }, - }, - ], - connectionId: null, - database: 'db3', - }, -]; - -const renderSavedDiagramsList = ({ - items = storageItems, -}: { - items?: MongoDBDataModelDescription[]; -} = {}) => { - const mockDataModelStorage = { - status: 'READY', - error: null, - items, - save: () => { - return Promise.resolve(false); - }, - delete: () => { - return Promise.resolve(false); - }, - loadAll: () => Promise.resolve(items), - load: (id: string) => { - return Promise.resolve(items.find((x) => x.id === id) ?? null); - }, - }; - return renderWithStore( - - - , - { - services: { - dataModelStorage: mockDataModelStorage, - }, - } - ); -}; - -describe('SavedDiagramsList', function () { - context('when there are no saved diagrams', function () { - let store: DataModelingStore; - - beforeEach(async function () { - const result = renderSavedDiagramsList({ items: [] }); - store = result.store; - - // wait till the empty list is loaded - await waitFor(() => { - expect(screen.getByTestId('empty-content')).to.be.visible; - }); - }); - - it('shows the empty state', function () { - expect(screen.getByText('Visualize your Data Model')).to.be.visible; - }); - - it('allows to start adding diagrams', function () { - const createDiagramButton = screen.getByRole('button', { - name: 'Generate diagram', - }); - expect(store.getState().generateDiagramWizard.inProgress).to.be.false; - expect(createDiagramButton).to.be.visible; - userEvent.click(createDiagramButton); - expect(store.getState().generateDiagramWizard.inProgress).to.be.true; - }); - }); - - context('when there are diagrams', function () { - let store: DataModelingStore; - - beforeEach(async function () { - const result = renderSavedDiagramsList(); - store = result.store; - - // wait till the list is loaded - await waitFor(() => { - expect(screen.getByTestId('saved-diagram-list')).to.be.visible; - }); - }); - - it('shows the list of diagrams', async function () { - await waitFor(() => { - expect(screen.getByText('One')).to.exist; - expect(screen.getByText('Two')).to.exist; - expect(screen.getByText('Three')).to.exist; - }); - }); - - it('allows to add another diagram', function () { - const createDiagramButton = screen.getByRole('button', { - name: 'Generate new diagram', - }); - expect(store.getState().generateDiagramWizard.inProgress).to.be.false; - expect(createDiagramButton).to.be.visible; - userEvent.click(createDiagramButton); - expect(store.getState().generateDiagramWizard.inProgress).to.be.true; - }); - - describe('search', function () { - it('filters the list of diagrams by name', async function () { - const searchInput = screen.getByPlaceholderText('Search'); - userEvent.type(searchInput, 'One'); - await waitFor(() => { - expect(screen.queryByText('One')).to.exist; - }); - - await waitFor(() => { - expect(screen.queryByText('Two')).to.not.exist; - expect(screen.queryByText('Three')).to.not.exist; - }); - }); - - it('filters the list of diagrams by database', async function () { - const searchInput = screen.getByPlaceholderText('Search'); - userEvent.type(searchInput, 'db2'); - await waitFor(() => { - expect(screen.queryByText('Two')).to.exist; - }); - - await waitFor(() => { - expect(screen.queryByText('One')).to.not.exist; - expect(screen.queryByText('Three')).to.not.exist; - }); - }); - - it('shows empty content when filter for a non-existent diagram', async function () { - const searchInput = screen.getByPlaceholderText('Search'); - userEvent.type(searchInput, 'Hello'); - await waitFor(() => { - expect(screen.queryByText('No results found.')).to.exist; - expect( - screen.queryByText( - "We can't find any diagram matching your search." - ) - ).to.exist; - }); - - await waitFor(() => { - expect(screen.queryByText('One')).to.not.exist; - expect(screen.queryByText('Two')).to.not.exist; - expect(screen.queryByText('Three')).to.not.exist; - }); - }); - }); - }); -}); diff --git a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx deleted file mode 100644 index 6306ada7858..00000000000 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { connect } from 'react-redux'; -import { createNewDiagram } from '../store/generate-diagram-wizard'; -import { - Button, - css, - EmptyContent, - spacing, - useSortControls, - useSortedItems, - VirtualGrid, - Link, - WorkspaceContainer, - Body, -} from '@mongodb-js/compass-components'; -import { useDataModelSavedItems } from '../provider'; -import { - deleteDiagram, - openDiagram, - openDiagramFromFile, - showDiagramRenameModal, -} from '../store/diagram'; -import type { MongoDBDataModelDescription } from '../services/data-model-storage'; -import CollaborateIcon from './icons/collaborate'; -import SchemaVisualizationIcon from './icons/schema-visualization'; -import FlexibilityIcon from './icons/flexibility'; -import { CARD_HEIGHT, CARD_WIDTH, DiagramCard } from './diagram-card'; -import { DiagramListToolbar } from './diagram-list-toolbar'; -import { ImportDiagramButton } from './import-diagram-button'; - -const sortBy = [ - { - name: 'name', - label: 'Name', - }, - { - name: 'updatedAt', - label: 'Last Modified', - }, -] as const; - -const listContainerStyles = css({ height: '100%' }); -const rowStyles = css({ - gap: spacing[200], - paddingLeft: spacing[400], - paddingRight: spacing[400], - paddingBottom: spacing[200], -}); - -export const DiagramListContext = React.createContext<{ - onSearchDiagrams: (search: string) => void; - onImportDiagram: (file: File) => void; - onCreateDiagram: () => void; - sortControls: React.ReactElement | null; - searchTerm: string; -}>({ - onSearchDiagrams: () => { - /** */ - }, - onImportDiagram: () => { - /** */ - }, - onCreateDiagram: () => { - /** */ - }, - sortControls: null, - searchTerm: '', -}); - -const subTitleStyles = css({ - maxWidth: '750px', -}); - -const diagramActionsStyles = css({ - display: 'flex', - gap: spacing[200], -}); - -const featuresListStyles = css({ - display: 'flex', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'flex-start', - gap: spacing[600], - marginTop: spacing[400], - marginBottom: spacing[400], -}); - -const featureItemTitleStyles = css({ - fontWeight: 'bold', -}); - -const featureItemStyles = css({ - display: 'grid', - gridTemplateRows: `${spacing[1800]}px 1fr 1fr`, - justifyItems: 'center', - gap: spacing[400], - width: spacing[1400] * 3, -}); - -type Feature = 'visualization' | 'collaboration' | 'interactive'; -const featureDescription: Record< - Feature, - { icon: React.FunctionComponent; title: string; subtitle: string } -> = { - visualization: { - icon: SchemaVisualizationIcon, - title: 'Quick Visualization', - subtitle: 'Instantly visualize your data models', - }, - collaboration: { - icon: CollaborateIcon, - title: 'Collaboration & Sharing with your team', - subtitle: 'Collaborate and share schemas across teams', - }, - interactive: { - icon: FlexibilityIcon, - title: 'Interactive Diagram Analysis', - subtitle: 'Explore and annotate interactive diagrams', - }, -} as const; - -const FeaturesList: React.FunctionComponent<{ features: Feature[] }> = ({ - features, -}) => { - return ( -
- {features.map((feature, key) => { - const { icon: Icon, title, subtitle } = featureDescription[feature]; - return ( -
- - {title} - {subtitle} -
- ); - })} -
- ); -}; - -const DiagramListEmptyContent: React.FunctionComponent<{ - onCreateDiagramClick: () => void; - onImportDiagramClick: (file: File) => void; -}> = ({ onCreateDiagramClick, onImportDiagramClick }) => { - return ( - - - Your data model is the foundation of application performance. As - applications evolve, so must your schema—intelligently and - strategically. Minimize complexity, prevent performance bottlenecks, - and keep your development agile. - - - Data modeling documentation - - - } - subTitleClassName={subTitleStyles} - callToAction={ -
- - -
- } - >
-
- ); -}; - -export const SavedDiagramsList: React.FunctionComponent<{ - onCreateDiagramClick: () => void; - onOpenDiagramClick: (diagram: MongoDBDataModelDescription) => void; - onDiagramDeleteClick: (id: string) => void; - onDiagramRenameClick: (id: string) => void; - onImportDiagramClick: (file: File) => void; -}> = ({ - onCreateDiagramClick, - onOpenDiagramClick, - onDiagramRenameClick, - onDiagramDeleteClick, - onImportDiagramClick, -}) => { - const { items, status } = useDataModelSavedItems(); - const [search, setSearch] = useState(''); - const filteredItems = useMemo(() => { - try { - const regex = new RegExp(search, 'i'); - return items.filter((x) => regex.test(x.name) || regex.test(x.database)); - } catch { - return items; - } - }, [items, search]); - const [sortControls, sortState] = useSortControls(sortBy); - const sortedItems = useSortedItems(filteredItems, sortState); - - if (status === 'INITIAL' || status === 'LOADING') { - return null; - } - if (items.length === 0) { - return ( - - ); - } - - return ( - - - ( - - )} - itemKey={(index) => sortedItems[index].id} - renderHeader={DiagramListToolbar} - headerHeight={spacing[800] * 3 + spacing[200]} - classNames={{ row: rowStyles }} - resetActiveItemOnBlur={false} - renderEmptyList={() => ( - - )} - > - - - ); -}; - -export default connect(null, { - onCreateDiagramClick: createNewDiagram, - onOpenDiagramClick: openDiagram, - onDiagramDeleteClick: deleteDiagram, - onDiagramRenameClick: showDiagramRenameModal, - onImportDiagramClick: openDiagramFromFile, -})(SavedDiagramsList); diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 4ae9eb66994..1edb20b16f2 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -21,7 +21,6 @@ import { getCoordinatesForNewNode, type openToast as _openToast, showConfirmation, - showPrompt, } from '@mongodb-js/compass-components'; import { getDiagramContentsFromFile, @@ -321,6 +320,7 @@ export const diagramReducer: Reducer = ( selectedItems: null, }; } + return state; }; @@ -707,31 +707,20 @@ export function deleteDiagram( export function renameDiagram( id: string, newName: string -): RenameDiagramAction { - return { type: DiagramActionTypes.RENAME_DIAGRAM, id, name: newName }; -} - -export function showDiagramRenameModal( - id: string // TODO maybe pass the whole thing here, we always have it when calling this, then we don't need to re-load storage ): DataModelingThunkAction, RenameDiagramAction> { - return async (dispatch, getState, { dataModelStorage }) => { + return async (dispatch, getState, { dataModelStorage, openToast }) => { try { const diagram = await dataModelStorage.load(id); - if (!diagram) { - return; - } - const newName = await showPrompt({ - title: 'Rename diagram', - label: 'Name', - defaultValue: diagram.name, - }); - if (!newName) { + if (!diagram || !newName) { return; } + + await dataModelStorage.save({ ...diagram, name: newName }); dispatch({ type: DiagramActionTypes.RENAME_DIAGRAM, id, name: newName }); - void dataModelStorage.save({ ...diagram, name: newName }); - } catch { - // TODO log + } catch (error) { + handleError(openToast, 'Error renaming diagram', [ + (error as Error).message, + ]); } }; } diff --git a/packages/compass-data-modeling/src/store/reducer.ts b/packages/compass-data-modeling/src/store/reducer.ts index 64bd7776997..0c3a016e031 100644 --- a/packages/compass-data-modeling/src/store/reducer.ts +++ b/packages/compass-data-modeling/src/store/reducer.ts @@ -26,6 +26,11 @@ import type { ReselectCollectionsWizardActionTypes, } from './reselect-collections-wizard'; import { reselectCollectionsWizardReducer } from './reselect-collections-wizard'; +import { + renameDiagramModalReducer, + type RenameDiagramModalActions, + type RenameDiagramModalActionTypes, +} from './rename-diagram-modal'; const reducer = combineReducers({ step: stepReducer, @@ -33,6 +38,7 @@ const reducer = combineReducers({ analysisProgress: analysisProcessReducer, diagram: diagramReducer, exportDiagram: exportDiagramReducer, + renameDiagramModal: renameDiagramModalReducer, reselectCollections: reselectCollectionsWizardReducer, }); @@ -41,12 +47,14 @@ export type DataModelingActions = | AnalysisProgressActions | DiagramActions | ExportDiagramActions + | RenameDiagramModalActions | ReselectCollectionsWizardActions; type _ActionTypes = typeof GenerateDiagramWizardActionTypes & typeof AnalysisProcessActionTypes & typeof DiagramActionTypes & typeof ExportDiagramActionTypes & + typeof RenameDiagramModalActionTypes & typeof ReselectCollectionsWizardActionTypes; export type DataModelingActionTypes = _ActionTypes[keyof _ActionTypes]; diff --git a/packages/compass-data-modeling/src/utils.ts b/packages/compass-data-modeling/src/utils.ts deleted file mode 100644 index 8a7a933422a..00000000000 --- a/packages/compass-data-modeling/src/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import toNS from 'mongodb-ns'; -import type { Relationship } from './services/data-model-storage'; - -export function getDefaultRelationshipName( - relationship: Relationship['relationship'] -): string { - const [local, foreign] = relationship; - let localLabel = ''; - let foreignLabel = ''; - if (local.ns) { - localLabel += toNS(local.ns).collection; - if (local.fields && local.fields.length) { - localLabel += `.${local.fields.join('.')}`; - } - } - if (foreign.ns) { - foreignLabel += toNS(foreign.ns).collection; - if (foreign.fields && foreign.fields.length) { - foreignLabel += `.${foreign.fields.join('.')}`; - } - } - return [localLabel, foreignLabel].join(` \u2192 `).trim(); -} diff --git a/packages/compass-data-modeling/src/utils/utils.ts b/packages/compass-data-modeling/src/utils/utils.ts index 12f0329d3d3..56071cebecb 100644 --- a/packages/compass-data-modeling/src/utils/utils.ts +++ b/packages/compass-data-modeling/src/utils/utils.ts @@ -5,6 +5,7 @@ import type { Relationship, } from '../services/data-model-storage'; import { cloneDeepWith } from 'lodash'; +import toNS from 'mongodb-ns'; export const isIdField = (fieldPath: FieldPath): boolean => fieldPath.length === 1 && fieldPath[0] === '_id'; @@ -143,3 +144,24 @@ export function mapFieldDataToJsonSchema( }); return newFieldData; } + +export function getDefaultRelationshipName( + relationship: Relationship['relationship'] +): string { + const [local, foreign] = relationship; + let localLabel = ''; + let foreignLabel = ''; + if (local.ns) { + localLabel += toNS(local.ns).collection; + if (local.fields && local.fields.length) { + localLabel += `.${local.fields.join('.')}`; + } + } + if (foreign.ns) { + foreignLabel += toNS(foreign.ns).collection; + if (foreign.fields && foreign.fields.length) { + foreignLabel += `.${foreign.fields.join('.')}`; + } + } + return [localLabel, foreignLabel].join(` \u2192 `).trim(); +} From c2c75bbeac7fee7768248e90ca7a8e081e64a42d Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 26 Jan 2026 12:06:38 +0100 Subject: [PATCH 2/5] cleanup --- .../compass-data-modeling/src/components/data-modeling.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/compass-data-modeling/src/components/data-modeling.tsx b/packages/compass-data-modeling/src/components/data-modeling.tsx index 26aeb247c8d..f2005a16220 100644 --- a/packages/compass-data-modeling/src/components/data-modeling.tsx +++ b/packages/compass-data-modeling/src/components/data-modeling.tsx @@ -45,7 +45,6 @@ const DeletedDiagramInfo: React.FunctionComponent = () => { const DataModeling: React.FunctionComponent = ({ showList, currentDiagramId, - renameDiagramId, }) => { const dataModels = useDataModelSavedItems(); const showDeletedInfo = useMemo(() => { @@ -68,7 +67,7 @@ const DataModeling: React.FunctionComponent = ({ )} - + ); }; @@ -77,6 +76,5 @@ export default connect((state: DataModelingState) => { return { showList: state.step === 'NO_DIAGRAM_SELECTED', currentDiagramId: state.diagram?.id, - renameDiagramId: state.renameDiagramModal?.diagramId, }; })(DataModeling); From bca18fae14189e95f381953be8ebe2fe89f6e9bd Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 26 Jan 2026 12:07:46 +0100 Subject: [PATCH 3/5] forgot to add --- .../src/components/list/diagram-card.spec.tsx | 47 +++ .../src/components/list/diagram-card.tsx | 141 +++++++++ .../components/list/diagram-list-toolbar.tsx | 94 ++++++ .../components/list/import-diagram-button.tsx | 45 +++ .../src/components/list/rename-modal.tsx | 106 +++++++ .../list/saved-diagrams-list.spec.tsx | 237 ++++++++++++++++ .../components/list/saved-diagrams-list.tsx | 268 ++++++++++++++++++ .../src/store/rename-diagram-modal.ts | 84 ++++++ .../src/utils/use-new-name-validation.ts | 38 +++ 9 files changed, 1060 insertions(+) create mode 100644 packages/compass-data-modeling/src/components/list/diagram-card.spec.tsx create mode 100644 packages/compass-data-modeling/src/components/list/diagram-card.tsx create mode 100644 packages/compass-data-modeling/src/components/list/diagram-list-toolbar.tsx create mode 100644 packages/compass-data-modeling/src/components/list/import-diagram-button.tsx create mode 100644 packages/compass-data-modeling/src/components/list/rename-modal.tsx create mode 100644 packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx create mode 100644 packages/compass-data-modeling/src/components/list/saved-diagrams-list.tsx create mode 100644 packages/compass-data-modeling/src/store/rename-diagram-modal.ts create mode 100644 packages/compass-data-modeling/src/utils/use-new-name-validation.ts diff --git a/packages/compass-data-modeling/src/components/list/diagram-card.spec.tsx b/packages/compass-data-modeling/src/components/list/diagram-card.spec.tsx new file mode 100644 index 00000000000..625a65e090d --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/diagram-card.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render, screen } from '@mongodb-js/testing-library-compass'; +import { DiagramCard } from './diagram-card'; +import type { Edit } from '../../services/data-model-storage'; + +describe('DiagramCard', () => { + const props = { + diagram: { + id: 'test-diagram', + connectionId: 'test-connection', + database: 'someDatabase', + name: 'Test Diagram', + createdAt: '2021-10-01T00:00:00.000Z', + updatedAt: '2023-10-03T00:00:00.000', + edits: [ + { + id: 'edit-id', + timestamp: '2022-10-01T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db.collection', + indexes: [], + displayPosition: [0, 0], + shardKey: {}, + fieldData: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ] as [Edit], + }, + onOpen: () => {}, + onDelete: () => {}, + onRename: () => {}, + }; + + it('renders name, database, last edited', () => { + render(); + expect(screen.getByText('Test Diagram')).to.be.visible; + expect(screen.getByText('someDatabase')).to.be.visible; + expect(screen.getByText('Last modified: October 3, 2023')).to.be.visible; + }); +}); diff --git a/packages/compass-data-modeling/src/components/list/diagram-card.tsx b/packages/compass-data-modeling/src/components/list/diagram-card.tsx new file mode 100644 index 00000000000..7f34e7556d1 --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/diagram-card.tsx @@ -0,0 +1,141 @@ +import { + Card, + css, + cx, + Icon, + ItemActionMenu, + palette, + spacing, + Subtitle, + useDarkMode, + useFormattedDate, +} from '@mongodb-js/compass-components'; +import type { MongoDBDataModelDescription } from '../../services/data-model-storage'; +import React from 'react'; + +// Same as saved-queries-aggregations +export const CARD_WIDTH = spacing[1600] * 4; +export const CARD_HEIGHT = 180; + +const diagramCardStyles = css({ + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}); + +const cardContentStyles = css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + justifyContent: 'flex-end', + gap: spacing[300], +}); + +const namespaceNameStyles = css({ + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', +}); + +const namespaceIconStyles = css({ + flexShrink: 0, +}); + +const lastModifiedLabel = css({ + fontStyle: 'italic', +}); + +const namespaceStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[200], +}); + +const cardHeaderStyles = css({ + display: 'flex', + gap: spacing[200], + alignItems: 'flex-start', +}); +const cardTitle = css({ + fontWeight: 'bold', + height: spacing[600] * 2, + marginBottom: spacing[400], + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', +}); + +const cardTitleDark = css({ + color: palette.green.light2, +}); +const cardTitleLight = css({ + color: palette.green.dark2, +}); + +export function DiagramCard({ + diagram, + onOpen, + onRename, + onDelete, +}: { + diagram: MongoDBDataModelDescription; + onOpen: (diagram: MongoDBDataModelDescription) => void; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; +}) { + const darkmode = useDarkMode(); + const formattedDate = useFormattedDate(new Date(diagram.updatedAt).getTime()); + return ( + onOpen(diagram)} + data-testid="saved-diagram-card" + data-diagram-name={diagram.name} + title={diagram.name} + > +
+ + {diagram.name} + + { + switch (action) { + case 'rename': + onRename(diagram.id, diagram.name); + break; + case 'delete': + onDelete(diagram.id); + break; + default: + break; + } + }} + > +
+
+
+ + {diagram.database} +
+
+ Last modified: {formattedDate} +
+
+
+ ); +} diff --git a/packages/compass-data-modeling/src/components/list/diagram-list-toolbar.tsx b/packages/compass-data-modeling/src/components/list/diagram-list-toolbar.tsx new file mode 100644 index 00000000000..db353a4a599 --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/diagram-list-toolbar.tsx @@ -0,0 +1,94 @@ +import React, { useContext } from 'react'; +import { + Button, + css, + cx, + Icon, + palette, + SearchInput, + spacing, + Subtitle, + useDarkMode, +} from '@mongodb-js/compass-components'; +import { DiagramListContext } from './saved-diagrams-list'; +import { ImportDiagramButton } from './import-diagram-button'; + +const containerStyles = css({ + padding: spacing[400], + display: 'grid', + gridTemplateAreas: ` + 'title createDiagram' + 'searchInput sortControls' + `, + columnGap: spacing[800], + rowGap: spacing[200], + gridTemplateColumns: '5fr', +}); + +const titleStyles = css({ + gridArea: 'title', +}); +const diagramActionsStyles = css({ + gridArea: 'createDiagram', + display: 'flex', + justifyContent: 'flex-end', + gap: spacing[200], +}); +const searchInputStyles = css({ + gridArea: 'searchInput', +}); +const sortControlsStyles = css({ + gridArea: 'sortControls', + display: 'flex', + justifyContent: 'flex-end', +}); + +const toolbarTitleLightStyles = css({ color: palette.gray.dark1 }); +const toolbarTitleDarkStyles = css({ color: palette.gray.light1 }); + +export const DiagramListToolbar = () => { + const { + onSearchDiagrams: onSearch, + onCreateDiagram, + sortControls, + searchTerm, + onImportDiagram, + } = useContext(DiagramListContext); + const darkMode = useDarkMode(); + + return ( +
+ + Open an existing diagram: + +
+ } + size="small" + onImportDiagram={onImportDiagram} + /> + +
+ onSearch(e.target.value)} + /> +
{sortControls}
+
+ ); +}; diff --git a/packages/compass-data-modeling/src/components/list/import-diagram-button.tsx b/packages/compass-data-modeling/src/components/list/import-diagram-button.tsx new file mode 100644 index 00000000000..1041b19bcf6 --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/import-diagram-button.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + Button, + type ButtonProps, + FileSelector, + Tooltip, +} from '@mongodb-js/compass-components'; + +type ImportDiagramButtonProps = Omit & { + onImportDiagram: (file: File) => void; +}; + +export const ImportDiagramButton = ({ + onImportDiagram, + ...buttonProps +}: ImportDiagramButtonProps) => { + return ( + + { + if (files.length === 0) { + return; + } + onImportDiagram(files[0]); + }} + trigger={({ onClick }) => ( + + )} + /> + + } + > + Only MDM files exported from Compass or Atlas Data Explorer can be + imported. + + ); +}; diff --git a/packages/compass-data-modeling/src/components/list/rename-modal.tsx b/packages/compass-data-modeling/src/components/list/rename-modal.tsx new file mode 100644 index 00000000000..1fe02385ff8 --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/rename-modal.tsx @@ -0,0 +1,106 @@ +import { + Button, + css, + FormFieldContainer, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + spacing, + TextInput, +} from '@mongodb-js/compass-components'; +import React, { useCallback, useMemo } from 'react'; +import { useNewNameValidation } from '../../utils/use-new-name-validation'; +import { renameDiagram } from '../../store/diagram'; +import { useDataModelSavedItems } from '../../provider'; +import type { DataModelingState } from '../../store/reducer'; +import { connect } from 'react-redux'; +import { closeRenameDiagramModal } from '../../store/rename-diagram-modal'; + +const inputStyles = css({ + height: `84px`, + minHeight: `84px`, +}); + +type RenameDiagramModalProps = { + isModalOpen: boolean; + diagramId?: string; + diagramName?: string; + onRename: (id: string, newName: string) => void; + onCloseClick: () => void; +}; + +const RenameDiagramModal: React.FC = ({ + isModalOpen, + diagramId, + diagramName: _diagramName, + onRename, + onCloseClick, +}) => { + const { items: savedDiagrams } = useDataModelSavedItems(); + const diagramNames = useMemo( + () => savedDiagrams.map((diagram) => diagram.name), + [savedDiagrams] + ); + + const [diagramName, setDiagramName] = React.useState( + _diagramName ?? '' + ); + + const handleDiagramNameChange = useCallback( + (event: React.ChangeEvent) => { + setDiagramName(event.target.value); + }, + [] + ); + + const handleRename = useCallback(() => { + if (diagramId) { + onRename(diagramId, diagramName); + } + }, [onRename, diagramId, diagramName]); + + const { isValid, errorMessage } = useNewNameValidation({ + existingNames: diagramNames, + currentName: _diagramName ?? '', + newName: diagramName, + entity: 'Diagram', + }); + + return ( + + + + + + + + + + + + ); +}; + +export default connect( + (state: DataModelingState) => { + return { + isModalOpen: state.renameDiagramModal?.isOpen ?? false, + diagramId: state.renameDiagramModal?.diagramId, + diagramName: state.renameDiagramModal?.diagramName, + key: state.renameDiagramModal?.diagramId, + }; + }, + { + onRename: renameDiagram, + onCloseClick: closeRenameDiagramModal, + } +)(RenameDiagramModal); diff --git a/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx new file mode 100644 index 00000000000..b542a6bdc6c --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { expect } from 'chai'; +import { + screen, + userEvent, + waitFor, +} from '@mongodb-js/testing-library-compass'; +import SavedDiagramsList from './saved-diagrams-list'; +import { renderWithStore } from '../../../test/setup-store'; +import type { DataModelingStore } from '../../../test/setup-store'; +import { DataModelStorageServiceProvider } from '../../provider'; +import type { MongoDBDataModelDescription } from '../../services/data-model-storage'; + +const storageItems: MongoDBDataModelDescription[] = [ + { + id: '1', + name: 'One', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-03T00:00:00.000Z', + edits: [ + { + id: 'edit-id-1', + timestamp: '2023-10-02T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db1.collection1', + indexes: [], + displayPosition: [1, 1], + shardKey: {}, + fieldData: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], + connectionId: null, + database: 'db1', + }, + { + id: '2', + name: 'Two', + createdAt: '2023-10-02T00:00:00.000Z', + updatedAt: '2023-10-04T00:00:00.000Z', + edits: [ + { + id: 'edit-id-2', + timestamp: '2023-10-01T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db2.collection2', + indexes: [], + displayPosition: [2, 2], + shardKey: {}, + fieldData: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], + connectionId: null, + database: 'db2', + }, + { + id: '3', + name: 'Three', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-05T00:00:00.000Z', + edits: [ + { + id: 'edit-id-3', + timestamp: '2023-10-01T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db3.collection3', + indexes: [], + displayPosition: [3, 3], + shardKey: {}, + fieldData: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], + connectionId: null, + database: 'db3', + }, +]; + +const renderSavedDiagramsList = ({ + items = storageItems, +}: { + items?: MongoDBDataModelDescription[]; +} = {}) => { + const mockDataModelStorage = { + status: 'READY', + error: null, + items, + save: () => { + return Promise.resolve(false); + }, + delete: () => { + return Promise.resolve(false); + }, + loadAll: () => Promise.resolve(items), + load: (id: string) => { + return Promise.resolve(items.find((x) => x.id === id) ?? null); + }, + }; + return renderWithStore( + + + , + { + services: { + dataModelStorage: mockDataModelStorage, + }, + } + ); +}; + +describe('SavedDiagramsList', function () { + context('when there are no saved diagrams', function () { + let store: DataModelingStore; + + beforeEach(async function () { + const result = renderSavedDiagramsList({ items: [] }); + store = result.store; + + // wait till the empty list is loaded + await waitFor(() => { + expect(screen.getByTestId('empty-content')).to.be.visible; + }); + }); + + it('shows the empty state', function () { + expect(screen.getByText('Visualize your Data Model')).to.be.visible; + }); + + it('allows to start adding diagrams', function () { + const createDiagramButton = screen.getByRole('button', { + name: 'Generate diagram', + }); + expect(store.getState().generateDiagramWizard.inProgress).to.be.false; + expect(createDiagramButton).to.be.visible; + userEvent.click(createDiagramButton); + expect(store.getState().generateDiagramWizard.inProgress).to.be.true; + }); + }); + + context('when there are diagrams', function () { + let store: DataModelingStore; + + beforeEach(async function () { + const result = renderSavedDiagramsList(); + store = result.store; + + // wait till the list is loaded + await waitFor(() => { + expect(screen.getByTestId('saved-diagram-list')).to.be.visible; + }); + }); + + it('shows the list of diagrams', async function () { + await waitFor(() => { + expect(screen.getByText('One')).to.exist; + expect(screen.getByText('Two')).to.exist; + expect(screen.getByText('Three')).to.exist; + }); + }); + + it('allows to add another diagram', function () { + const createDiagramButton = screen.getByRole('button', { + name: 'Generate new diagram', + }); + expect(store.getState().generateDiagramWizard.inProgress).to.be.false; + expect(createDiagramButton).to.be.visible; + userEvent.click(createDiagramButton); + expect(store.getState().generateDiagramWizard.inProgress).to.be.true; + }); + + describe('search', function () { + it('filters the list of diagrams by name', async function () { + const searchInput = screen.getByPlaceholderText('Search'); + userEvent.type(searchInput, 'One'); + await waitFor(() => { + expect(screen.queryByText('One')).to.exist; + }); + + await waitFor(() => { + expect(screen.queryByText('Two')).to.not.exist; + expect(screen.queryByText('Three')).to.not.exist; + }); + }); + + it('filters the list of diagrams by database', async function () { + const searchInput = screen.getByPlaceholderText('Search'); + userEvent.type(searchInput, 'db2'); + await waitFor(() => { + expect(screen.queryByText('Two')).to.exist; + }); + + await waitFor(() => { + expect(screen.queryByText('One')).to.not.exist; + expect(screen.queryByText('Three')).to.not.exist; + }); + }); + + it('shows empty content when filter for a non-existent diagram', async function () { + const searchInput = screen.getByPlaceholderText('Search'); + userEvent.type(searchInput, 'Hello'); + await waitFor(() => { + expect(screen.queryByText('No results found.')).to.exist; + expect( + screen.queryByText( + "We can't find any diagram matching your search." + ) + ).to.exist; + }); + + await waitFor(() => { + expect(screen.queryByText('One')).to.not.exist; + expect(screen.queryByText('Two')).to.not.exist; + expect(screen.queryByText('Three')).to.not.exist; + }); + }); + }); + }); +}); diff --git a/packages/compass-data-modeling/src/components/list/saved-diagrams-list.tsx b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.tsx new file mode 100644 index 00000000000..ee84f2d2199 --- /dev/null +++ b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.tsx @@ -0,0 +1,268 @@ +import React, { useMemo, useState } from 'react'; +import { connect } from 'react-redux'; +import { createNewDiagram } from '../../store/generate-diagram-wizard'; +import { + Button, + css, + EmptyContent, + spacing, + useSortControls, + useSortedItems, + VirtualGrid, + Link, + WorkspaceContainer, + Body, +} from '@mongodb-js/compass-components'; +import { useDataModelSavedItems } from '../../provider'; +import { + deleteDiagram, + openDiagram, + openDiagramFromFile, +} from '../../store/diagram'; +import type { MongoDBDataModelDescription } from '../../services/data-model-storage'; +import CollaborateIcon from '../icons/collaborate'; +import SchemaVisualizationIcon from '../icons/schema-visualization'; +import FlexibilityIcon from '../icons/flexibility'; +import { CARD_HEIGHT, CARD_WIDTH, DiagramCard } from './diagram-card'; +import { DiagramListToolbar } from './diagram-list-toolbar'; +import { ImportDiagramButton } from './import-diagram-button'; +import { openRenameDiagramModal } from '../../store/rename-diagram-modal'; + +const sortBy = [ + { + name: 'name', + label: 'Name', + }, + { + name: 'updatedAt', + label: 'Last Modified', + }, +] as const; + +const listContainerStyles = css({ height: '100%' }); +const rowStyles = css({ + gap: spacing[200], + paddingLeft: spacing[400], + paddingRight: spacing[400], + paddingBottom: spacing[200], +}); + +export const DiagramListContext = React.createContext<{ + onSearchDiagrams: (search: string) => void; + onImportDiagram: (file: File) => void; + onCreateDiagram: () => void; + sortControls: React.ReactElement | null; + searchTerm: string; +}>({ + onSearchDiagrams: () => { + /** */ + }, + onImportDiagram: () => { + /** */ + }, + onCreateDiagram: () => { + /** */ + }, + sortControls: null, + searchTerm: '', +}); + +const subTitleStyles = css({ + maxWidth: '750px', +}); + +const diagramActionsStyles = css({ + display: 'flex', + gap: spacing[200], +}); + +const featuresListStyles = css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'flex-start', + gap: spacing[600], + marginTop: spacing[400], + marginBottom: spacing[400], +}); + +const featureItemTitleStyles = css({ + fontWeight: 'bold', +}); + +const featureItemStyles = css({ + display: 'grid', + gridTemplateRows: `${spacing[1800]}px 1fr 1fr`, + justifyItems: 'center', + gap: spacing[400], + width: spacing[1400] * 3, +}); + +type Feature = 'visualization' | 'collaboration' | 'interactive'; +const featureDescription: Record< + Feature, + { icon: React.FunctionComponent; title: string; subtitle: string } +> = { + visualization: { + icon: SchemaVisualizationIcon, + title: 'Quick Visualization', + subtitle: 'Instantly visualize your data models', + }, + collaboration: { + icon: CollaborateIcon, + title: 'Collaboration & Sharing with your team', + subtitle: 'Collaborate and share schemas across teams', + }, + interactive: { + icon: FlexibilityIcon, + title: 'Interactive Diagram Analysis', + subtitle: 'Explore and annotate interactive diagrams', + }, +} as const; + +const FeaturesList: React.FunctionComponent<{ features: Feature[] }> = ({ + features, +}) => { + return ( +
+ {features.map((feature, key) => { + const { icon: Icon, title, subtitle } = featureDescription[feature]; + return ( +
+ + {title} + {subtitle} +
+ ); + })} +
+ ); +}; + +const DiagramListEmptyContent: React.FunctionComponent<{ + onCreateDiagramClick: () => void; + onImportDiagramClick: (file: File) => void; +}> = ({ onCreateDiagramClick, onImportDiagramClick }) => { + return ( + + + Your data model is the foundation of application performance. As + applications evolve, so must your schema—intelligently and + strategically. Minimize complexity, prevent performance bottlenecks, + and keep your development agile. + + + Data modeling documentation + + + } + subTitleClassName={subTitleStyles} + callToAction={ +
+ + +
+ } + >
+
+ ); +}; + +export const SavedDiagramsList: React.FunctionComponent<{ + onCreateDiagramClick: () => void; + onOpenDiagramClick: (diagram: MongoDBDataModelDescription) => void; + onDiagramDeleteClick: (id: string) => void; + onDiagramRenameClick: (id: string, name: string) => void; + onImportDiagramClick: (file: File) => void; +}> = ({ + onCreateDiagramClick, + onOpenDiagramClick, + onDiagramRenameClick, + onDiagramDeleteClick, + onImportDiagramClick, +}) => { + const { items, status } = useDataModelSavedItems(); + const [search, setSearch] = useState(''); + const filteredItems = useMemo(() => { + try { + const regex = new RegExp(search, 'i'); + return items.filter((x) => regex.test(x.name) || regex.test(x.database)); + } catch { + return items; + } + }, [items, search]); + const [sortControls, sortState] = useSortControls(sortBy); + const sortedItems = useSortedItems(filteredItems, sortState); + + if (status === 'INITIAL' || status === 'LOADING') { + return null; + } + if (items.length === 0) { + return ( + + ); + } + + return ( + + + ( + + )} + itemKey={(index) => sortedItems[index].id} + renderHeader={DiagramListToolbar} + headerHeight={spacing[800] * 3 + spacing[200]} + classNames={{ row: rowStyles }} + resetActiveItemOnBlur={false} + renderEmptyList={() => ( + + )} + > + + + ); +}; + +export default connect(null, { + onCreateDiagramClick: createNewDiagram, + onOpenDiagramClick: openDiagram, + onDiagramDeleteClick: deleteDiagram, + onDiagramRenameClick: openRenameDiagramModal, + onImportDiagramClick: openDiagramFromFile, +})(SavedDiagramsList); diff --git a/packages/compass-data-modeling/src/store/rename-diagram-modal.ts b/packages/compass-data-modeling/src/store/rename-diagram-modal.ts new file mode 100644 index 00000000000..c01bf29d74a --- /dev/null +++ b/packages/compass-data-modeling/src/store/rename-diagram-modal.ts @@ -0,0 +1,84 @@ +import type { Reducer } from 'redux'; +import { isAction } from './util'; +import { DiagramActionTypes } from './diagram'; + +export type RenameDiagramModalState = { + isOpen: boolean; + diagramId?: string; + diagramName?: string; +}; + +export const RenameDiagramModalActionTypes = { + OPEN_RENAME_DIAGRAM_MODAL: + 'data-modeling/rename-diagram/OPEN_RENAME_DIAGRAM_MODAL', + CLOSE_RENAME_DIAGRAM_MODAL: + 'data-modeling/rename-diagram/CLOSE_RENAME_DIAGRAM_MODAL', +} as const; + +type ModalOpenedAction = { + type: typeof RenameDiagramModalActionTypes.OPEN_RENAME_DIAGRAM_MODAL; + id: string; + name: string; +}; + +type ModalClosedAction = { + type: typeof RenameDiagramModalActionTypes.CLOSE_RENAME_DIAGRAM_MODAL; +}; + +export type OpenRenameDiagramModalAction = { + type: typeof RenameDiagramModalActionTypes.OPEN_RENAME_DIAGRAM_MODAL; + id: string; + name: string; +}; + +export type CloseRenameDiagramModalAction = { + type: typeof RenameDiagramModalActionTypes.CLOSE_RENAME_DIAGRAM_MODAL; +}; + +export type RenameDiagramModalActions = ModalOpenedAction | ModalClosedAction; + +const INITIAL_STATE = { + isOpen: false, + diagramId: undefined, +}; + +export const renameDiagramModalReducer: Reducer = ( + state = INITIAL_STATE, + action +) => { + if ( + isAction(action, RenameDiagramModalActionTypes.OPEN_RENAME_DIAGRAM_MODAL) + ) { + return { + ...state, + isOpen: true, + diagramId: action.id, + diagramName: action.name, + }; + } + if ( + isAction( + action, + RenameDiagramModalActionTypes.CLOSE_RENAME_DIAGRAM_MODAL + ) || + isAction(action, DiagramActionTypes.RENAME_DIAGRAM) + ) { + return INITIAL_STATE; + } + return state; +}; + +export function openRenameDiagramModal( + id: string, + name: string +): OpenRenameDiagramModalAction { + return { + type: RenameDiagramModalActionTypes.OPEN_RENAME_DIAGRAM_MODAL, + id, + name, + }; +} + +export function closeRenameDiagramModal() { + return { type: RenameDiagramModalActionTypes.CLOSE_RENAME_DIAGRAM_MODAL }; +} diff --git a/packages/compass-data-modeling/src/utils/use-new-name-validation.ts b/packages/compass-data-modeling/src/utils/use-new-name-validation.ts new file mode 100644 index 00000000000..ea83e5b1bd7 --- /dev/null +++ b/packages/compass-data-modeling/src/utils/use-new-name-validation.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +export function useNewNameValidation({ + newName, + existingNames, + currentName, + entity, +}: { + newName: string; + existingNames: string[]; + currentName: string; + entity: string; +}): { + isValid: boolean; + errorMessage?: string; +} { + return useMemo(() => { + if (newName.trim().length === 0) { + return { + isValid: false, + errorMessage: `${entity} name cannot be empty.`, + }; + } + + const existingNamesWithoutCurrent = existingNames.filter( + (name) => name !== currentName + ); + + const isDuplicate = existingNamesWithoutCurrent.some( + (name) => name.trim() === newName.trim() + ); + + return { + isValid: !isDuplicate, + errorMessage: isDuplicate ? `${entity} name must be unique.` : undefined, + }; + }, [newName, existingNames, currentName, entity]); +} From 12ed5c9e4054ebbb396b1858f938bb98312798ef Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 26 Jan 2026 12:42:29 +0100 Subject: [PATCH 4/5] add test --- .../src/components/list/rename-modal.tsx | 1 - .../list/saved-diagrams-list.spec.tsx | 64 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/compass-data-modeling/src/components/list/rename-modal.tsx b/packages/compass-data-modeling/src/components/list/rename-modal.tsx index 1fe02385ff8..771552b0d50 100644 --- a/packages/compass-data-modeling/src/components/list/rename-modal.tsx +++ b/packages/compass-data-modeling/src/components/list/rename-modal.tsx @@ -6,7 +6,6 @@ import { ModalBody, ModalFooter, ModalHeader, - spacing, TextInput, } from '@mongodb-js/compass-components'; import React, { useCallback, useMemo } from 'react'; diff --git a/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx index b542a6bdc6c..85185b5f602 100644 --- a/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx +++ b/packages/compass-data-modeling/src/components/list/saved-diagrams-list.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { expect } from 'chai'; +import sinon from 'sinon'; import { screen, userEvent, @@ -10,6 +11,7 @@ import { renderWithStore } from '../../../test/setup-store'; import type { DataModelingStore } from '../../../test/setup-store'; import { DataModelStorageServiceProvider } from '../../provider'; import type { MongoDBDataModelDescription } from '../../services/data-model-storage'; +import RenameModal from './rename-modal'; const storageItems: MongoDBDataModelDescription[] = [ { @@ -97,16 +99,16 @@ const storageItems: MongoDBDataModelDescription[] = [ const renderSavedDiagramsList = ({ items = storageItems, + onSave = () => Promise.resolve(false), }: { items?: MongoDBDataModelDescription[]; + onSave?: (diagram: MongoDBDataModelDescription) => Promise; } = {}) => { const mockDataModelStorage = { status: 'READY', error: null, items, - save: () => { - return Promise.resolve(false); - }, + save: onSave, delete: () => { return Promise.resolve(false); }, @@ -118,6 +120,7 @@ const renderSavedDiagramsList = ({ return renderWithStore( + , { services: { @@ -158,9 +161,13 @@ describe('SavedDiagramsList', function () { context('when there are diagrams', function () { let store: DataModelingStore; + let saveSpy: sinon.SinonSpy; beforeEach(async function () { - const result = renderSavedDiagramsList(); + saveSpy = sinon.spy(); + const result = renderSavedDiagramsList({ + onSave: saveSpy, + }); store = result.store; // wait till the list is loaded @@ -187,6 +194,55 @@ describe('SavedDiagramsList', function () { expect(store.getState().generateDiagramWizard.inProgress).to.be.true; }); + it('allows renaming a diagram', async function () { + const actionsBtns = screen.getAllByRole('button', { + name: 'Show actions', + }); + expect(actionsBtns.length).to.equal(3); + + // Open actions menu for the first diagram + userEvent.click(actionsBtns[0]); + + // Click Rename + const renameBtn = screen.getByRole('menuitem', { name: 'Rename' }); + expect(renameBtn).to.be.visible; + userEvent.click(renameBtn); + + // Wait for the modal to open + await waitFor(() => { + expect(screen.getByText('Rename diagram')).to.be.visible; + }); + + const nameInput = screen.getByLabelText('Name'); + const submitButton = screen.getByRole('button', { name: 'Rename' }); + expect(nameInput).to.have.attribute('aria-invalid', 'false'); + + // Empty is disabled + userEvent.clear(nameInput); + expect(nameInput).to.have.attribute('aria-invalid', 'true'); + expect(submitButton).to.have.attribute('aria-disabled', 'true'); + + // Duplicate is disabled + userEvent.type(nameInput, 'Two'); + expect(nameInput).to.have.attribute('aria-invalid', 'true'); + expect(submitButton).to.have.attribute('aria-disabled', 'true'); + + // Valid name enables the Rename button + userEvent.clear(nameInput); + userEvent.type(nameInput, 'New Name'); + expect(nameInput).to.have.attribute('aria-invalid', 'false'); + expect(submitButton).to.have.attribute('aria-disabled', 'false'); + userEvent.click(submitButton); + + // Wait for the modal to close + await waitFor(() => { + expect(screen.queryByText('Rename diagram')).to.not.exist; + expect(saveSpy).to.have.been.calledOnceWith( + sinon.match.has('name', 'New Name') + ); + }); + }); + describe('search', function () { it('filters the list of diagrams by name', async function () { const searchInput = screen.getByPlaceholderText('Search'); From f30c3fb059075189256468f2fa2938f59d41a5f8 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Mon, 26 Jan 2026 14:08:01 +0100 Subject: [PATCH 5/5] . --- .../src/components/list/rename-modal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/compass-data-modeling/src/components/list/rename-modal.tsx b/packages/compass-data-modeling/src/components/list/rename-modal.tsx index 771552b0d50..bf76fbd9b07 100644 --- a/packages/compass-data-modeling/src/components/list/rename-modal.tsx +++ b/packages/compass-data-modeling/src/components/list/rename-modal.tsx @@ -67,7 +67,12 @@ const RenameDiagramModal: React.FC = ({ }); return ( - + { + if (!open) onCloseClick(); + }} + >