diff --git a/configs/mocha-config-compass/register/jsdom-extra-mocks-register.js b/configs/mocha-config-compass/register/jsdom-extra-mocks-register.js index f5c4e59b682..64eb311ed42 100644 --- a/configs/mocha-config-compass/register/jsdom-extra-mocks-register.js +++ b/configs/mocha-config-compass/register/jsdom-extra-mocks-register.js @@ -49,3 +49,5 @@ if (!window.document.queryCommandSupported) { globalThis.EventTarget = window.EventTarget; globalThis.CustomEvent = window.CustomEvent; globalThis.Event = window.Event; +globalThis.Blob = window.Blob; +globalThis.File = window.File; diff --git a/packages/compass-components/src/components/file-input.spec.tsx b/packages/compass-components/src/components/file-picker-dialog.spec.tsx similarity index 99% rename from packages/compass-components/src/components/file-input.spec.tsx rename to packages/compass-components/src/components/file-picker-dialog.spec.tsx index b61f218cd70..40ce148a4ed 100644 --- a/packages/compass-components/src/components/file-input.spec.tsx +++ b/packages/compass-components/src/components/file-picker-dialog.spec.tsx @@ -14,7 +14,7 @@ import { import FileInput, { FileInputBackendProvider, createElectronFileInputBackend, -} from './file-input'; +} from './file-picker-dialog'; describe('FileInput', function () { let spy; diff --git a/packages/compass-components/src/components/file-input.tsx b/packages/compass-components/src/components/file-picker-dialog.tsx similarity index 97% rename from packages/compass-components/src/components/file-input.tsx rename to packages/compass-components/src/components/file-picker-dialog.tsx index 2bb2e0addb2..e71e3477563 100644 --- a/packages/compass-components/src/components/file-input.tsx +++ b/packages/compass-components/src/components/file-picker-dialog.tsx @@ -311,7 +311,17 @@ export function createElectronFileInputBackend( }; } -function FileInput({ +/** + * This component is not intended to work in a browser environment. It is designed + * to be used in environments like Electron where you have access to nodes fs module + * to read/write files. + * + * Always use `FileSelector` component, only use `FilePickerDialog` if you absolutely + * know what you're doing + * + * @deprecated + */ +function FilePickerDialog({ autoOpen = false, id, label, @@ -553,4 +563,4 @@ function FileInput({ ); } -export default FileInput; +export default FilePickerDialog; diff --git a/packages/compass-components/src/components/file-selector.tsx b/packages/compass-components/src/components/file-selector.tsx new file mode 100644 index 00000000000..4939e5dd130 --- /dev/null +++ b/packages/compass-components/src/components/file-selector.tsx @@ -0,0 +1,43 @@ +import React, { type InputHTMLAttributes, useRef } from 'react'; + +type FileSelectorTriggerProps = { + onClick: () => void; +}; + +type FileSelectorProps = Omit< + InputHTMLAttributes, + 'onChange' | 'onSelect' | 'type' | 'style' | 'ref' +> & { + trigger: (props: FileSelectorTriggerProps) => React.ReactElement; + onSelect: (files: File[]) => void; +}; + +export function FileSelector({ + trigger, + onSelect, + ...props +}: FileSelectorProps) { + const inputRef = useRef(null); + + const onFilesChanged = React.useCallback( + (evt: React.ChangeEvent) => { + onSelect(Array.from(evt.currentTarget.files ?? [])); + }, + [onSelect] + ); + + return ( + <> + + {trigger({ + onClick: () => inputRef.current?.click(), + })} + + ); +} diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 40d6a609b93..8981d156a29 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -18,12 +18,12 @@ import type { ElectronFileDialogOptions, ElectronShowFileDialogProvider, FileInputBackend, -} from './components/file-input'; -import FileInput, { +} from './components/file-picker-dialog'; +import FilePickerDialog, { createElectronFileInputBackend, createJSDomFileInputDummyBackend, FileInputBackendProvider, -} from './components/file-input'; +} from './components/file-picker-dialog'; import { OptionsToggle } from './components/options-toggle'; import { ErrorSummary, @@ -118,7 +118,7 @@ export { CollapsibleFieldSet, ConfirmationModal, ErrorSummary, - FileInput, + FilePickerDialog, FileInputBackendProvider, IndexIcon, OptionsToggle, @@ -219,3 +219,4 @@ export { export { SelectList } from './components/select-list'; export { ParagraphSkeleton } from '@leafygreen-ui/skeleton-loader'; export { InsightsChip } from './components/insights-chip'; +export { FileSelector } from './components/file-selector'; diff --git a/packages/compass-connection-import-export/src/components/file-input.tsx b/packages/compass-connection-import-export/src/components/file-input.tsx index dc227f55c9a..1f45f5ef016 100644 --- a/packages/compass-connection-import-export/src/components/file-input.tsx +++ b/packages/compass-connection-import-export/src/components/file-input.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { FileInput as CompassFileInput } from '@mongodb-js/compass-components'; +import { FilePickerDialog } from '@mongodb-js/compass-components'; type FileInputProps = { label: string; @@ -24,7 +24,7 @@ export function FileInput({ ); return ( - { onCreateDiagram, sortControls, searchTerm, + onImportDiagram, } = useContext(DiagramListContext); const darkMode = useDarkMode(); @@ -61,7 +66,12 @@ export const DiagramListToolbar = () => { > Open an existing diagram: -
+
+ } + size="small" + onImportDiagram={onImportDiagram} + /> + )} + /> + ); +}; diff --git a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx index 9af9ba67429..108c7928b58 100644 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx +++ b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx @@ -18,6 +18,7 @@ import { deleteDiagram, selectCurrentModel, openDiagram, + openDiagramFromFile, renameDiagram, } from '../store/diagram'; import type { MongoDBDataModelDescription } from '../services/data-model-storage'; @@ -27,6 +28,7 @@ import FlexibilityIcon from './icons/flexibility'; import { CARD_HEIGHT, CARD_WIDTH, DiagramCard } from './diagram-card'; import { DiagramListToolbar } from './diagram-list-toolbar'; import toNS from 'mongodb-ns'; +import { ImportDiagramButton } from './import-diagram-button'; const sortBy = [ { @@ -49,6 +51,7 @@ const rowStyles = css({ export const DiagramListContext = React.createContext<{ onSearchDiagrams: (search: string) => void; + onImportDiagram: (file: File) => void; onCreateDiagram: () => void; sortControls: React.ReactElement | null; searchTerm: string; @@ -56,6 +59,9 @@ export const DiagramListContext = React.createContext<{ onSearchDiagrams: () => { /** */ }, + onImportDiagram: () => { + /** */ + }, onCreateDiagram: () => { /** */ }, @@ -67,6 +73,11 @@ const subTitleStyles = css({ maxWidth: '750px', }); +const diagramActionsStyles = css({ + display: 'flex', + gap: spacing[200], +}); + const featuresListStyles = css({ display: 'flex', flexDirection: 'row', @@ -132,7 +143,8 @@ const FeaturesList: React.FunctionComponent<{ features: Feature[] }> = ({ const DiagramListEmptyContent: React.FunctionComponent<{ onCreateDiagramClick: () => void; -}> = ({ onCreateDiagramClick }) => { + onImportDiagramClick: (file: File) => void; +}> = ({ onCreateDiagramClick, onImportDiagramClick }) => { return ( - Generate diagram - +
+ + +
} >
@@ -171,11 +186,13 @@ export const SavedDiagramsList: React.FunctionComponent<{ 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 decoratedItems = useMemo< @@ -214,7 +231,10 @@ export const SavedDiagramsList: React.FunctionComponent<{ } if (items.length === 0) { return ( - + ); } @@ -225,6 +245,7 @@ export const SavedDiagramsList: React.FunctionComponent<{ searchTerm: search, onCreateDiagram: onCreateDiagramClick, onSearchDiagrams: setSearch, + onImportDiagram: onImportDiagramClick, }} > @@ -264,4 +285,5 @@ export default connect(null, { onOpenDiagramClick: openDiagram, onDiagramDeleteClick: deleteDiagram, onDiagramRenameClick: renameDiagram, + onImportDiagramClick: openDiagramFromFile, })(SavedDiagramsList); diff --git a/packages/compass-data-modeling/src/services/data-model-storage.ts b/packages/compass-data-modeling/src/services/data-model-storage.ts index 0f93708bb5f..4924fe957f8 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -67,8 +67,19 @@ const EditSchemaVariants = z.discriminatedUnion('type', [ ]); export const EditSchema = z.intersection(EditSchemaBase, EditSchemaVariants); +export const EditListSchema = z + .array(EditSchema) + .nonempty() + // Ensure first item exists and is 'SetModel' + .refine((edits) => edits[0]?.type === 'SetModel', { + message: "First edit must be of type 'SetModel'", + }); export type Edit = z.output; +export type SetModelEdit = Extract< + z.output, + { type: 'SetModel' } +>; export type EditAction = z.output; @@ -100,15 +111,7 @@ export const MongoDBDataModelDescriptionSchema = z.object({ * anything that would require re-fetching data associated with the diagram */ connectionId: z.string().nullable(), - - // Ensure first item exists and is 'SetModel' - edits: z - .array(EditSchema) - .nonempty() - .refine((edits) => edits[0]?.type === 'SetModel', { - message: "First edit must be of type 'SetModel'", - }), - + edits: EditListSchema, createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); diff --git a/packages/compass-data-modeling/src/services/open-and-download-diagram.spec.ts b/packages/compass-data-modeling/src/services/open-and-download-diagram.spec.ts index 2958ac98e59..bf75795762f 100644 --- a/packages/compass-data-modeling/src/services/open-and-download-diagram.spec.ts +++ b/packages/compass-data-modeling/src/services/open-and-download-diagram.spec.ts @@ -1,6 +1,10 @@ import { expect } from 'chai'; -import { getDownloadDiagramContent } from './open-and-download-diagram'; -import FlightDiagram from '../../test/fixtures/flights-diagram.json'; +import { + getDownloadDiagramContent, + getDiagramName, + getDiagramContentsFromFile, +} from './open-and-download-diagram'; +import FlightDiagram from '../../test/fixtures/data-model-with-relationships.json'; describe('open-and-download-diagram', function () { it('should return correct content to download', function () { @@ -21,4 +25,169 @@ describe('open-and-download-diagram', function () { ); expect(decodedEdits).to.deep.equal(FlightDiagram.edits); }); + + context('getDiagramName', function () { + const usecases = [ + { + existingNames: [], + name: 'Airbnb', + expectedName: 'Airbnb', + message: 'should return the expected name when it does not exist', + }, + { + existingNames: ['Airbnb'], + name: 'Airbnb', + expectedName: 'Airbnb (1)', + message: 'should return the next expected name when it exists', + }, + { + existingNames: ['Airbnb (1)'], + name: 'Airbnb (1)', + expectedName: 'Airbnb (2)', + message: + 'should return the next expected name when name with (number) exists', + }, + { + existingNames: ['Airbnb', 'Airbnb (1)', 'Airbnb (2)'], + name: 'Airbnb (1)', + expectedName: 'Airbnb (3)', + message: + 'should return the next expected name when multiple versions exist', + }, + ]; + + for (const { existingNames, name, expectedName, message } of usecases) { + it(message, function () { + const result = getDiagramName(existingNames, name); + expect(result).to.equal(expectedName); + }); + } + }); + + context('getDiagramContentsFromFile', function () { + const makeFile = ( + content: string, + fileName: string = 'diagram.json', + type: string = 'application/json' + ) => { + const blob = new Blob([content], { type }); + return new File([blob], fileName, { type }); + }; + const errorUsecases = [ + { + title: 'should throw an error for a file with invalid JSON', + file: makeFile('invalid content', 'invalid.txt', 'text/plain'), + expected: 'Failed to parse diagram file', + }, + { + title: + 'should throw an error if content.version is not the current version', + file: makeFile( + JSON.stringify({ version: 0, type: 'Compass Data Modeling Diagram' }) + ), + expected: 'Unsupported diagram file format', + }, + { + title: 'should throw an error if content.type is not the current type', + file: makeFile( + JSON.stringify({ version: 1, type: 'Compass Data Modeling' }) + ), + expected: 'Unsupported diagram file format', + }, + { + title: 'should throw if name or edits are missing', + file: makeFile( + JSON.stringify({ version: 1, type: 'Compass Data Modeling Diagram' }) + ), + expected: 'Diagram file is missing required fields', + }, + { + title: 'should throw if name or edits is not a string', + file: makeFile( + JSON.stringify({ + version: 1, + type: 'Compass Data Modeling Diagram', + name: 'Test diagram', + edits: [], + }) + ), + expected: 'Diagram file is missing required fields', + }, + { + title: 'should throw if edits is invalid base64', + file: makeFile( + JSON.stringify({ + version: 1, + type: 'Compass Data Modeling Diagram', + name: 'Test Diagram', + edits: 'something', + }) + ), + expected: 'Failed to parse diagram file', + }, + { + title: 'should throw if edits is valid base64 but not valid schema', + file: makeFile( + JSON.stringify({ + version: 1, + type: 'Compass Data Modeling Diagram', + name: 'Test Diagram', + edits: Buffer.from( + JSON.stringify([{ type: 'NonExistent' }]) + ).toString('base64'), + }) + ), + expected: 'Failed to parse diagram file: Invalid diagram data.', + }, + { + title: 'should throw if first edit is not SetModel', + file: makeFile( + JSON.stringify({ + version: 1, + type: 'Compass Data Modeling Diagram', + name: 'Test Diagram', + edits: Buffer.from( + JSON.stringify([ + { + type: 'MoveCollection', + ns: 'test', + newPosition: [0, 0], + id: '123e4567-e89b-12d3-a456-426614174000', + timestamp: new Date().toISOString(), + }, + ]) + ).toString('base64'), + }) + ), + expected: 'Failed to parse diagram file: Invalid diagram data.', + }, + ]; + for (const { title, file, expected } of errorUsecases) { + it(title, async function () { + try { + await getDiagramContentsFromFile(file); + expect.fail('Expected an error to be thrown'); + } catch (error) { + expect((error as Error).message).to.contain(expected); + } + }); + } + + it('should return the correct diagram contents from a valid file', async function () { + const file = makeFile( + JSON.stringify({ + version: 1, + type: 'Compass Data Modeling Diagram', + name: 'Test Diagram', + edits: Buffer.from(JSON.stringify(FlightDiagram.edits)).toString( + 'base64' + ), + }) + ); + + const { name, edits } = await getDiagramContentsFromFile(file); + expect(name).to.equal('Test Diagram'); + expect(edits).to.deep.equal(FlightDiagram.edits); + }); + }); }); diff --git a/packages/compass-data-modeling/src/services/open-and-download-diagram.ts b/packages/compass-data-modeling/src/services/open-and-download-diagram.ts index a3e4771559d..9d21a4343df 100644 --- a/packages/compass-data-modeling/src/services/open-and-download-diagram.ts +++ b/packages/compass-data-modeling/src/services/open-and-download-diagram.ts @@ -1,5 +1,7 @@ -import type { Edit } from './data-model-storage'; +import type { SetModelEdit } from './data-model-storage'; +import { EditListSchema, type Edit } from './data-model-storage'; import { downloadFile } from './export-diagram'; +import { z } from '@mongodb-js/compass-user-data'; const kCurrentVersion = 1; const kFileTypeDescription = 'Compass Data Modeling Diagram'; @@ -25,3 +27,80 @@ export function getDownloadDiagramContent(name: string, edits: Edit[]) { edits: Buffer.from(JSON.stringify(edits)).toString('base64'), }; } + +export async function getDiagramContentsFromFile( + file: File +): Promise<{ name: string; edits: [SetModelEdit, ...Edit[]] }> { + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onload = (event) => { + try { + const content = event.target?.result; + if (typeof content !== 'string') { + throw new Error('Invalid file contents'); + } + const parsedContent = JSON.parse(content); + + if ( + parsedContent.version !== kCurrentVersion || + parsedContent.type !== kFileTypeDescription + ) { + throw new Error('Unsupported diagram file format'); + } + + const { name, edits } = parsedContent; + + if (!name || !edits || typeof edits !== 'string') { + throw new Error('Diagram file is missing required fields'); + } + + const parsedEdits = JSON.parse( + Buffer.from(edits, 'base64').toString('utf-8') + ); + // Ensure that edits validate using EditListSchema (SetModel must be first) + const validEdits = EditListSchema.parse(parsedEdits); + + return resolve({ + name: parsedContent.name, + edits: [validEdits[0] as SetModelEdit, ...validEdits.slice(1)], + }); + } catch (error) { + const message = + error instanceof z.ZodError + ? 'Failed to parse diagram file: Invalid diagram data.' + : `Failed to parse diagram file: ${(error as Error).message}`; + reject(new Error(message)); + } + }; + reader.onerror = (error) => { + reject(error.target?.error || new Error('File read error')); + }; + reader.readAsText(file); + }); +} + +function getNameAndCount(expectedName: string): [string, number] { + const { groups = {} } = + expectedName.match(/^(?.+?)(\s\((?\d+)\))?$/) ?? {}; + return [groups.name ?? expectedName, groups.count ? Number(groups.count) : 0]; +} + +export function getDiagramName( + existingNames: string[], + expectedName: string +): string { + if (!existingNames.includes(expectedName)) { + return expectedName; + } + const [initialName, initialCount] = getNameAndCount(expectedName); + + const finalCount = existingNames.reduce((accumulatedCount, name) => { + const [baseName, count] = getNameAndCount(name); + if (baseName === initialName && count >= accumulatedCount) { + return count + 1; + } + return accumulatedCount; + }, initialCount + 1); + + return `${initialName} (${finalCount})`; +} diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index a34eb73611c..ce940c709ff 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -11,9 +11,17 @@ import { import { AnalysisProcessActionTypes } from './analysis-process'; import { memoize } from 'lodash'; import type { DataModelingState, DataModelingThunkAction } from './reducer'; -import { showConfirmation, showPrompt } from '@mongodb-js/compass-components'; +import { + openToast, + showConfirmation, + showPrompt, +} from '@mongodb-js/compass-components'; +import { + downloadDiagram, + getDiagramContentsFromFile, + getDiagramName, +} from '../services/open-and-download-diagram'; import type { MongoDBJSONSchema } from 'mongodb-schema'; -import { downloadDiagram } from '../services/open-and-download-diagram'; function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { return Array.isArray(arr) && arr.length > 0; @@ -403,7 +411,9 @@ export function applyInitialLayout( }; } -export function openDiagram(diagram: MongoDBDataModelDescription) { +export function openDiagram( + diagram: MongoDBDataModelDescription +): OpenDiagramAction { return { type: DiagramActionTypes.OPEN_DIAGRAM, diagram }; } @@ -460,6 +470,37 @@ export function renameDiagram( }; } +export function openDiagramFromFile( + file: File +): DataModelingThunkAction, OpenDiagramAction> { + return async (dispatch, getState, { dataModelStorage }) => { + try { + const { name, edits } = await getDiagramContentsFromFile(file); + + const existingDiagramNames = (await dataModelStorage.loadAll()).map( + (diagram) => diagram.name + ); + + const diagram: MongoDBDataModelDescription = { + id: new UUID().toString(), + name: getDiagramName(existingDiagramNames, name), + connectionId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + edits, + }; + dispatch(openDiagram(diagram)); + void dataModelStorage.save(diagram); + } catch (error) { + openToast('data-modeling-file-read-error', { + variant: 'warning', + title: 'Error opening diagram', + description: (error as Error).message, + }); + } + }; +} + export function updateRelationship( relationship: Relationship ): DataModelingThunkAction { diff --git a/packages/compass-data-modeling/test/fixtures/flights-diagram.json b/packages/compass-data-modeling/test/fixtures/flights-diagram.json deleted file mode 100644 index 2d0c1a6908b..00000000000 --- a/packages/compass-data-modeling/test/fixtures/flights-diagram.json +++ /dev/null @@ -1,303 +0,0 @@ -{ - "id": "26fea481-14a0-40de-aa8e-b3ef22afcf1b", - "connectionId": "108acc00-4d7b-4f56-be19-05c7288da71a", - "name": "Flights and countries", - "edits": [ - { - "id": "5e16572a-6978-4669-8103-e1f087b412cd", - "timestamp": "2025-06-20T06:35:26.773Z", - "type": "SetModel", - "model": { - "collections": [ - { - "ns": "flights.airlines", - "jsonSchema": { - "bsonType": "object", - "required": [ - "_id", - "active", - "airline", - "alias", - "base", - "country", - "iata", - "icao", - "name" - ], - "properties": { - "_id": { - "bsonType": "objectId" - }, - "active": { - "bsonType": "string" - }, - "airline": { - "bsonType": "int" - }, - "alias": { - "bsonType": ["string", "int"] - }, - "alliance": { - "bsonType": "string" - }, - "base": { - "bsonType": "string" - }, - "country": { - "bsonType": "string" - }, - "iata": { - "bsonType": "string" - }, - "icao": { - "bsonType": "string" - }, - "name": { - "bsonType": "string" - } - } - }, - "indexes": [], - "displayPosition": [144.04516098441445, 226.78180342288712] - }, - { - "ns": "flights.airports", - "jsonSchema": { - "bsonType": "object", - "required": [ - "_id", - "Altitude", - "Country", - "IATA", - "ICAO", - "Latitude", - "Longitude", - "Name" - ], - "properties": { - "_id": { - "bsonType": "int" - }, - "Altitude": { - "bsonType": "int" - }, - "City": { - "bsonType": "string" - }, - "Country": { - "bsonType": "string" - }, - "IATA": { - "bsonType": "string" - }, - "ICAO": { - "bsonType": "string" - }, - "Latitude": { - "bsonType": "double" - }, - "Longitude": { - "bsonType": "double" - }, - "Name": { - "bsonType": "string" - } - } - }, - "indexes": [], - "displayPosition": [157.74741328703078, 614.6105002761217] - }, - { - "ns": "flights.airports_coordinates_for_schema", - "jsonSchema": { - "bsonType": "object", - "required": ["_id", "coordinates", "Country", "Name"], - "properties": { - "_id": { - "bsonType": "int" - }, - "coordinates": { - "bsonType": "array", - "items": { - "bsonType": "double" - } - }, - "Country": { - "bsonType": "string" - }, - "Name": { - "bsonType": "string" - } - } - }, - "indexes": [], - "displayPosition": [611.3592580503537, 238.3680626820135] - }, - { - "ns": "flights.countries", - "jsonSchema": { - "bsonType": "object", - "required": ["_id", "iso_code", "name"], - "properties": { - "_id": { - "bsonType": "objectId" - }, - "dafif_code": { - "bsonType": "string" - }, - "iso_code": { - "bsonType": "string" - }, - "name": { - "bsonType": "string" - } - } - }, - "indexes": [], - "displayPosition": [156.9088146439409, 808.1350158017262] - }, - { - "ns": "flights.planes", - "jsonSchema": { - "bsonType": "object", - "required": ["_id", "IATA", "ICAO", "name"], - "properties": { - "_id": { - "bsonType": "objectId" - }, - "IATA": { - "bsonType": "string" - }, - "ICAO": { - "bsonType": "string" - }, - "name": { - "bsonType": "string" - } - } - }, - "indexes": [], - "displayPosition": [479.9432289278143, 650.1759375929954] - }, - { - "ns": "flights.routes", - "jsonSchema": { - "bsonType": "object", - "required": [ - "_id", - "airline", - "airline_id", - "destination_airport", - "destination_airport_id", - "equipment", - "source_airport", - "source_airport_id", - "stops" - ], - "properties": { - "_id": { - "bsonType": "objectId" - }, - "airline": { - "bsonType": "string" - }, - "airline_id": { - "bsonType": "string" - }, - "codeshare": { - "bsonType": "string" - }, - "destination_airport": { - "bsonType": "string" - }, - "destination_airport_id": { - "bsonType": "string" - }, - "equipment": { - "bsonType": "string" - }, - "source_airport": { - "bsonType": "string" - }, - "source_airport_id": { - "bsonType": "string" - }, - "stops": { - "bsonType": "int" - } - } - }, - "indexes": [], - "displayPosition": [853.3477815091105, 168.4596944341812] - } - ], - "relationships": [] - } - }, - { - "id": "cfba18e8-ffe6-4222-9c60-e063a31303b4", - "timestamp": "2025-06-20T06:36:04.745Z", - "type": "AddRelationship", - "relationship": { - "id": "6f776467-4c98-476b-9b71-1f8a724e6c2c", - "relationship": [ - { - "ns": "flights.airlines", - "cardinality": 1, - "fields": ["country"] - }, - { - "ns": "flights.countries", - "cardinality": 1, - "fields": ["name"] - } - ], - "isInferred": false - } - }, - { - "id": "74383587-5f0a-4b43-8eba-b810cc058c5b", - "timestamp": "2025-06-20T06:36:32.785Z", - "type": "AddRelationship", - "relationship": { - "id": "204b1fc0-601f-4d62-bba3-38fade71e049", - "relationship": [ - { - "ns": "flights.countries", - "cardinality": 1, - "fields": ["name"] - }, - { - "ns": "flights.airports", - "cardinality": 1, - "fields": ["Country"] - } - ], - "isInferred": false - } - }, - { - "type": "MoveCollection", - "ns": "flights.airports_coordinates_for_schema", - "newPosition": [477.4524339160448, 212.09520478224402], - "id": "538f3bcd-6f0c-49fd-9fc7-ffe7cf5a4718", - "timestamp": "2025-07-16T14:34:20.394Z" - }, - { - "type": "MoveCollection", - "ns": "flights.countries", - "newPosition": [506.93108279248247, 415.73717039549217], - "id": "5fb8b8af-4687-4521-8ed7-832156c12c22", - "timestamp": "2025-07-16T14:34:24.792Z" - }, - { - "type": "MoveCollection", - "ns": "flights.planes", - "newPosition": [548.5916640852764, 701.8741418473805], - "id": "7acf2862-5c29-40f4-8133-e976ae85b20d", - "timestamp": "2025-07-16T14:34:25.872Z" - } - ], - "createdAt": "2025-06-20T06:35:26.773Z", - "updatedAt": "2025-07-16T14:34:25.872Z" -} diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 97e015f35e2..69a30fb2f47 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -1430,6 +1430,7 @@ export const AutoUpdateReleaseNotesLink = // Data Modeling export const SidebarDataModelingTab = `${Sidebar} [aria-label="Data Modeling"]`; +export const ImportDataModelInput = '[data-testid="import-diagram-file-input"]'; export const CreateNewDataModelButton = '[data-testid="create-diagram-button"]'; export const CreateDataModelModal = '[data-testid="new-diagram-modal"]'; export const CreateDataModelConfirmButton = `${CreateDataModelModal} [data-testid="new-diagram-confirm-button"]`; @@ -1455,8 +1456,13 @@ export const DataModelExportPngOption = `${DataModelExportModal} input[aria-labe export const DataModelExportJsonOption = `${DataModelExportModal} input[aria-label="JSON"]`; export const DataModelExportModalConfirmButton = '[data-testid="export-button"]'; -export const DataModelsListItem = (diagramName: string) => - `[data-testid="saved-diagram-card"][data-diagram-name="${diagramName}"]`; +export const DataModelsListItem = (diagramName?: string) => { + const diagramListSelector = `[data-testid="saved-diagram-card"]`; + if (diagramName) { + return `${diagramListSelector}[data-diagram-name="${diagramName}"]`; + } + return diagramListSelector; +}; export const DataModelsListItemActions = (diagramName: string) => `${DataModelsListItem(diagramName)} [aria-label="Show actions"]`; export const DataModelsListItemDeleteButton = `[data-action="delete"]`; 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 a4417da0c6c..e13543d242b 100644 --- a/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts +++ b/packages/compass-e2e-tests/tests/data-modeling-tab.test.ts @@ -386,7 +386,7 @@ describe('Data Modeling tab', function () { expect(text).to.include('String string'.toLowerCase()); }); - it('downloads the data model', async function () { + it('downloads the data model and opens it', async function () { const dataModelName = 'Test Export Model - Save-Open'; exportFileName = `${dataModelName}.compass`; await setupDiagram(browser, { @@ -426,5 +426,30 @@ describe('Data Modeling tab', function () { expect(edits).to.be.an('array').of.length(2); expect(edits[0].type).to.equal('SetModel'); expect(edits[1].type).to.equal('MoveCollection'); + + // Open the saved diagram + await browser.closeWorkspaceTabs(); + await browser.navigateToDataModeling(); + + await browser.selectFile(Selectors.ImportDataModelInput, filePath); + await browser.$(Selectors.DataModelEditor).waitForDisplayed(); + const savedNodes = await getDiagramNodes(browser); + + expect(savedNodes).to.have.lengthOf(2); + expect(savedNodes[0].id).to.equal('test.testCollection-one'); + expect(savedNodes[1].id).to.equal('test.testCollection-two'); + + // Ensure that two diagrams exist (with correct incremental name) + await browser.closeWorkspaceTabs(); + await browser.navigateToDataModeling(); + + const cardsSelector = Selectors.DataModelsListItem(); + await browser.waitForAnimations(cardsSelector); + const titles = await browser + .$$(cardsSelector) + .map((element) => element.getAttribute('data-diagram-name')); + expect(titles).to.include(dataModelName); + // The second one is the one we just opened + expect(titles).to.include(`${dataModelName} (1)`); }); }); diff --git a/packages/compass-import-export/src/components/import-file-input.tsx b/packages/compass-import-export/src/components/import-file-input.tsx index 81ef6e13bb5..8b60bd92644 100644 --- a/packages/compass-import-export/src/components/import-file-input.tsx +++ b/packages/compass-import-export/src/components/import-file-input.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { FileInput } from '@mongodb-js/compass-components'; +import { FilePickerDialog } from '@mongodb-js/compass-components'; type ImportFileInputProps = { autoOpen?: boolean; @@ -28,7 +28,7 @@ function ImportFileInput({ const values = fileName ? [fileName] : undefined; return ( - - { diff --git a/packages/connection-form/src/components/advanced-options-tabs/tls-ssl-tab/tls-certificate-authority.tsx b/packages/connection-form/src/components/advanced-options-tabs/tls-ssl-tab/tls-certificate-authority.tsx index 1471160a44a..c0204edb176 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/tls-ssl-tab/tls-certificate-authority.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/tls-ssl-tab/tls-certificate-authority.tsx @@ -1,5 +1,8 @@ import React from 'react'; -import { FormFieldContainer, FileInput } from '@mongodb-js/compass-components'; +import { + FormFieldContainer, + FilePickerDialog, +} from '@mongodb-js/compass-components'; function TLSCertificateAuthority({ tlsCAFile, @@ -15,7 +18,7 @@ function TLSCertificateAuthority({ return ( <> - -