diff --git a/packages/compass-components/src/hooks/use-formatted-date.tsx b/packages/compass-components/src/hooks/use-formatted-date.tsx index d2ff80c2e9e..8ba19ad230b 100644 --- a/packages/compass-components/src/hooks/use-formatted-date.tsx +++ b/packages/compass-components/src/hooks/use-formatted-date.tsx @@ -2,15 +2,21 @@ import { formatDate } from '../utils/format-date'; import { useState, useEffect } from 'react'; -export function useFormattedDate(timestamp: number) { +export function useFormattedDate(timestamp: number): string; +export function useFormattedDate(timestamp?: number): string | undefined; +export function useFormattedDate(timestamp?: number): string | undefined { const [formattedDate, setFormattedDate] = useState(() => - formatDate(timestamp) + typeof timestamp === 'number' ? formatDate(timestamp) : undefined ); useEffect(() => { - setFormattedDate(formatDate(timestamp)); + setFormattedDate( + typeof timestamp === 'number' ? formatDate(timestamp) : undefined + ); const interval = setInterval(() => { - setFormattedDate(formatDate(timestamp)); + setFormattedDate( + typeof timestamp === 'number' ? formatDate(timestamp) : undefined + ); }, 1000 * 60); return () => { clearInterval(interval); diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 1857adc3c99..d577dd2bf13 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -167,7 +167,7 @@ export { mergeProps } from './utils/merge-props'; export { focusRing, useFocusRing } from './hooks/use-focus-ring'; export { useDefaultAction } from './hooks/use-default-action'; export { useSortControls, useSortedItems } from './hooks/use-sort'; -export { useFormattedDate } from './hooks/use-formatted-date'; +export * from './hooks/use-formatted-date'; export { fontFamilies } from '@leafygreen-ui/tokens'; export { default as BSONValue } from './components/bson-value'; export * as DocumentList from './components/document-list'; diff --git a/packages/compass-data-modeling/src/components/diagram-card.spec.tsx b/packages/compass-data-modeling/src/components/diagram-card.spec.tsx new file mode 100644 index 00000000000..3f97c7e97a0 --- /dev/null +++ b/packages/compass-data-modeling/src/components/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', + name: 'Test Diagram', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-03T00:00:00.000Z', + edits: [ + { + id: 'edit-id', + timestamp: '2023-10-01T00:00:00.000Z', + type: 'SetModel', + model: { + collections: [ + { + ns: 'db.collection', + indexes: [], + displayPosition: [0, 0], + shardKey: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ] as [Edit], + databases: 'someDatabase', + }, + 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 index b4edae0e7c5..3df4dc4241f 100644 --- a/packages/compass-data-modeling/src/components/diagram-card.tsx +++ b/packages/compass-data-modeling/src/components/diagram-card.tsx @@ -2,18 +2,20 @@ 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 = 218; +export const CARD_HEIGHT = 180; const diagramCardStyles = css({ display: 'flex', @@ -21,6 +23,34 @@ const diagramCardStyles = css({ 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], @@ -48,12 +78,15 @@ export function DiagramCard({ onRename, onDelete, }: { - diagram: MongoDBDataModelDescription; + diagram: MongoDBDataModelDescription & { + databases: string; + }; onOpen: (diagram: MongoDBDataModelDescription) => void; onRename: (id: string) => void; onDelete: (id: string) => void; }) { const darkmode = useDarkMode(); + const formattedDate = useFormattedDate(new Date(diagram.updatedAt).getTime()); return ( - {/* TODO(COMPASS-9398): Add lastModified and namespace to the card. */} +
+
+ + {diagram.databases} +
+
+ Last modified: {formattedDate} +
+
); } 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 index eee6e79f8f4..56b7e49203c 100644 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx +++ b/packages/compass-data-modeling/src/components/saved-diagrams-list.spec.tsx @@ -15,19 +15,79 @@ const storageItems: MongoDBDataModelDescription[] = [ { id: '1', name: 'One', - edits: [], + 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: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], connectionId: null, }, { id: '2', name: 'Two', - edits: [], + 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: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], connectionId: null, }, { id: '3', name: 'Three', - edits: [], + 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: {}, + jsonSchema: { bsonType: 'object' }, + }, + ], + relationships: [], + }, + }, + ], connectionId: null, }, ]; @@ -126,33 +186,50 @@ describe('SavedDiagramsList', function () { expect(store.getState().generateDiagramWizard.inProgress).to.be.true; }); - it('filters the list of diagrams', 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; + 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('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; + 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; + }); }); - await waitFor(() => { - expect(screen.queryByText('One')).to.not.exist; - expect(screen.queryByText('Two')).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 index b87eb740a42..5431f3a3b31 100644 --- a/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx +++ b/packages/compass-data-modeling/src/components/saved-diagrams-list.tsx @@ -13,7 +13,12 @@ import { WorkspaceContainer, } from '@mongodb-js/compass-components'; import { useDataModelSavedItems } from '../provider'; -import { deleteDiagram, openDiagram, renameDiagram } from '../store/diagram'; +import { + deleteDiagram, + getCurrentModel, + openDiagram, + renameDiagram, +} from '../store/diagram'; import type { MongoDBDataModelDescription } from '../services/data-model-storage'; import CollaborateIcon from './icons/collaborate'; import SchemaVisualizationIcon from './icons/schema-visualization'; @@ -21,17 +26,17 @@ import FlexibilityIcon from './icons/flexibility'; import InsightIcon from './icons/insight'; import { CARD_HEIGHT, CARD_WIDTH, DiagramCard } from './diagram-card'; import { DiagramListToolbar } from './diagram-list-toolbar'; +import toNS from 'mongodb-ns'; const sortBy = [ { name: 'name', label: 'Name', }, - // TODO(COMPASS-9398): Currently we do not have lastModified. - // { - // name: 'lastModified', - // label: 'Last Modified', - // }, + { + name: 'updatedAt', + label: 'Last Modified', + }, ] as const; const listContainerStyles = css({ height: '100%' }); @@ -134,24 +139,38 @@ export const SavedDiagramsList: React.FunctionComponent<{ onDiagramDeleteClick, }) => { const { items, status } = useDataModelSavedItems(); + const decoratedItems = useMemo< + (MongoDBDataModelDescription & { + databases: string; + })[] + >(() => { + return items.map((item) => { + const databases = new Set( + getCurrentModel(item).collections.map(({ ns }) => toNS(ns).database) + ); + return { + ...item, + databases: Array.from(databases).join(', '), + }; + }); + }, [items]); const [search, setSearch] = useState(''); const filteredItems = useMemo(() => { try { const regex = new RegExp(search, 'i'); - // TODO(COMPASS-9398): Currently only searching for name. - // We want to include more fields like namespace. - return items.filter((x) => regex.test(x.name)); + return decoratedItems.filter( + (x) => regex.test(x.name) || (x.databases && regex.test(x.databases)) + ); } catch { - return items; + return decoratedItems; } - }, [items, search]); + }, [decoratedItems, search]); const [sortControls, sortState] = useSortControls(sortBy); const sortedItems = useSortedItems(filteredItems, sortState); if (status === 'INITIAL' || status === 'LOADING') { return null; } - if (items.length === 0) { return ( 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 4aef7938400..2af8b35012a 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage.ts +++ b/packages/compass-data-modeling/src/services/data-model-storage.ts @@ -88,7 +88,10 @@ export const MongoDBDataModelDescriptionSchema = z.object({ */ connectionId: z.string().nullable(), - edits: z.array(EditSchema).default([]), + edits: z.array(EditSchema).nonempty(), + + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), }); export type MongoDBDataModelDescription = z.output< diff --git a/packages/compass-data-modeling/src/store/diagram.spec.ts b/packages/compass-data-modeling/src/store/diagram.spec.ts index 5fd96312c98..14600c15a8d 100644 --- a/packages/compass-data-modeling/src/store/diagram.spec.ts +++ b/packages/compass-data-modeling/src/store/diagram.spec.ts @@ -55,6 +55,8 @@ const loadedDiagram: MongoDBDataModelDescription = { id: 'diagram-id', name: 'diagram-name', connectionId: 'connection-id', + createdAt: '2023-10-01T00:00:00.000Z', + updatedAt: '2023-10-05T00:00:00.000Z', edits: [{ type: 'SetModel', model } as Edit], }; diff --git a/packages/compass-data-modeling/src/store/diagram.ts b/packages/compass-data-modeling/src/store/diagram.ts index 238de33edc9..2b86d1efa3a 100644 --- a/packages/compass-data-modeling/src/store/diagram.ts +++ b/packages/compass-data-modeling/src/store/diagram.ts @@ -12,11 +12,15 @@ import { memoize } from 'lodash'; import type { DataModelingState, DataModelingThunkAction } from './reducer'; import { showConfirmation, showPrompt } from '@mongodb-js/compass-components'; +function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { + return Array.isArray(arr) && arr.length > 0; +} + export type DiagramState = | (Omit & { edits: { prev: Edit[][]; - current: Edit[]; + current: [Edit, ...Edit[]]; next: Edit[][]; }; editErrors?: string[]; @@ -84,9 +88,7 @@ export const diagramReducer: Reducer = ( ) => { if (isAction(action, DiagramActionTypes.OPEN_DIAGRAM)) { return { - id: action.diagram.id, - connectionId: action.diagram.connectionId, - name: action.diagram.name, + ...action.diagram, edits: { prev: [], current: action.diagram.edits, @@ -100,6 +102,8 @@ export const diagramReducer: Reducer = ( id: new UUID().toString(), name: action.name, connectionId: action.connectionId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), edits: { prev: [], current: [ @@ -134,6 +138,7 @@ export const diagramReducer: Reducer = ( return { ...state, name: action.name, + updatedAt: new Date().toISOString(), }; } if (isAction(action, DiagramActionTypes.APPLY_EDIT)) { @@ -145,6 +150,7 @@ export const diagramReducer: Reducer = ( next: [], }, editErrors: undefined, + updatedAt: new Date().toISOString(), }; } if (isAction(action, DiagramActionTypes.APPLY_EDIT_FAILED)) { @@ -154,8 +160,8 @@ export const diagramReducer: Reducer = ( }; } if (isAction(action, DiagramActionTypes.UNDO_EDIT)) { - const newCurrent = state.edits.prev.pop(); - if (!newCurrent) { + const newCurrent = state.edits.prev.pop() || []; + if (!isNonEmptyArray(newCurrent)) { return state; } return { @@ -165,11 +171,12 @@ export const diagramReducer: Reducer = ( current: newCurrent, next: [...state.edits.next, state.edits.current], }, + updatedAt: new Date().toISOString(), }; } if (isAction(action, DiagramActionTypes.REDO_EDIT)) { - const newCurrent = state.edits.next.pop(); - if (!newCurrent) { + const newCurrent = state.edits.next.pop() || []; + if (!isNonEmptyArray(newCurrent)) { return state; } return { @@ -179,6 +186,7 @@ export const diagramReducer: Reducer = ( current: newCurrent, next: [...state.edits.next], }, + updatedAt: new Date().toISOString(), }; } return state; @@ -341,10 +349,12 @@ export function getCurrentDiagramFromState( id, connectionId, name, + createdAt, + updatedAt, edits: { current: edits }, } = state.diagram; - return { id, connectionId, name, edits }; + return { id, connectionId, name, edits, createdAt, updatedAt }; } export const selectCurrentModel = memoize(getCurrentModel);