Skip to content

Commit 8eb7366

Browse files
authored
feat(data-modeling): add rename collection to side panel COMPASS-9658 (#7174)
1 parent ca62aa9 commit 8eb7366

File tree

7 files changed

+280
-24
lines changed

7 files changed

+280
-24
lines changed

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-data-modeling/src/components/drawer/collection-drawer-content.tsx

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React from 'react';
1+
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
22
import { connect } from 'react-redux';
3+
import toNS from 'mongodb-ns';
34
import type { Relationship } from '../../services/data-model-storage';
45
import {
56
Badge,
@@ -15,6 +16,7 @@ import {
1516
import {
1617
createNewRelationship,
1718
deleteRelationship,
19+
renameCollection,
1820
selectCurrentModelFromState,
1921
selectRelationship,
2022
updateCollectionNote,
@@ -29,12 +31,14 @@ import { useChangeOnBlur } from './use-change-on-blur';
2931

3032
type CollectionDrawerContentProps = {
3133
namespace: string;
34+
namespaces: string[];
35+
note?: string;
3236
relationships: Relationship[];
3337
onCreateNewRelationshipClick: (namespace: string) => void;
3438
onEditRelationshipClick: (rId: string) => void;
3539
onDeleteRelationshipClick: (rId: string) => void;
36-
note?: string;
3740
onNoteChange: (namespace: string, note: string) => void;
41+
onRenameCollection: (fromNS: string, toNS: string) => void;
3842
};
3943

4044
const titleBtnStyles = css({
@@ -70,17 +74,77 @@ const relationshipContentStyles = css({
7074
marginTop: spacing[400],
7175
});
7276

77+
export function getIsCollectionNameValid(
78+
collectionName: string,
79+
namespaces: string[],
80+
namespace: string
81+
): {
82+
isValid: boolean;
83+
errorMessage?: string;
84+
} {
85+
if (collectionName.trim().length === 0) {
86+
return {
87+
isValid: false,
88+
errorMessage: 'Collection name cannot be empty.',
89+
};
90+
}
91+
92+
const namespacesWithoutCurrent = namespaces.filter((ns) => ns !== namespace);
93+
94+
const isDuplicate = namespacesWithoutCurrent.some(
95+
(ns) =>
96+
ns === `${toNS(namespace).database}.${collectionName}` ||
97+
ns === `${toNS(namespace).database}.${collectionName.trim()}`
98+
);
99+
100+
return {
101+
isValid: !isDuplicate,
102+
errorMessage: isDuplicate ? 'Collection name must be unique.' : undefined,
103+
};
104+
}
105+
73106
const CollectionDrawerContent: React.FunctionComponent<
74107
CollectionDrawerContentProps
75108
> = ({
76109
namespace,
110+
namespaces,
111+
note = '',
77112
relationships,
78113
onCreateNewRelationshipClick,
79114
onEditRelationshipClick,
80115
onDeleteRelationshipClick,
81-
note = '',
82116
onNoteChange,
117+
onRenameCollection,
83118
}) => {
119+
const [collectionName, setCollectionName] = useState(
120+
() => toNS(namespace).collection
121+
);
122+
123+
const {
124+
isValid: isCollectionNameValid,
125+
errorMessage: collectionNameEditErrorMessage,
126+
} = useMemo(
127+
() => getIsCollectionNameValid(collectionName, namespaces, namespace),
128+
[collectionName, namespaces, namespace]
129+
);
130+
131+
useLayoutEffect(() => {
132+
setCollectionName(toNS(namespace).collection);
133+
}, [namespace]);
134+
135+
const onBlurCollectionName = useCallback(() => {
136+
const trimmedName = collectionName.trim();
137+
if (trimmedName === toNS(namespace).collection) {
138+
return;
139+
}
140+
141+
if (!isCollectionNameValid) {
142+
return;
143+
}
144+
145+
onRenameCollection(namespace, `${toNS(namespace).database}.${trimmedName}`);
146+
}, [collectionName, namespace, onRenameCollection, isCollectionNameValid]);
147+
84148
const noteInputProps = useChangeOnBlur(note, (newNote) => {
85149
onNoteChange(namespace, newNote);
86150
});
@@ -92,8 +156,13 @@ const CollectionDrawerContent: React.FunctionComponent<
92156
<TextInput
93157
label="Name"
94158
sizeVariant="small"
95-
value={namespace}
96-
disabled={true}
159+
value={collectionName}
160+
state={isCollectionNameValid ? undefined : 'error'}
161+
errorMessage={collectionNameEditErrorMessage}
162+
onChange={(e) => {
163+
setCollectionName(e.target.value);
164+
}}
165+
onBlur={onBlurCollectionName}
97166
/>
98167
</DMFormFieldContainer>
99168
</DMDrawerSection>
@@ -182,6 +251,7 @@ export default connect(
182251
model.collections.find((collection) => {
183252
return collection.ns === ownProps.namespace;
184253
})?.note ?? '',
254+
namespaces: model.collections.map((c) => c.ns),
185255
relationships: model.relationships.filter((r) => {
186256
const [local, foreign] = r.relationship;
187257
return (
@@ -195,5 +265,6 @@ export default connect(
195265
onEditRelationshipClick: selectRelationship,
196266
onDeleteRelationshipClick: deleteRelationship,
197267
onNoteChange: updateCollectionNote,
268+
onRenameCollection: renameCollection,
198269
}
199270
)(CollectionDrawerContent);

packages/compass-data-modeling/src/components/drawer/diagram-editor-side-panel.spec.tsx

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import dataModel from '../../../test/fixtures/data-model-with-relationships.json';
1919
import type {
2020
MongoDBDataModelDescription,
21+
DataModelCollection,
2122
Relationship,
2223
} from '../../services/data-model-storage';
2324
import { DrawerAnchor } from '@mongodb-js/compass-components';
@@ -73,12 +74,12 @@ describe('DiagramEditorSidePanel', function () {
7374
result.plugin.store.dispatch(selectCollection('flights.airlines'));
7475

7576
await waitFor(() => {
76-
expect(screen.getByTitle('flights.airlines')).to.exist;
77+
expect(screen.getByTitle('flights.airlines')).to.be.visible;
7778
});
7879

7980
const nameInput = screen.getByLabelText('Name');
8081
expect(nameInput).to.be.visible;
81-
expect(nameInput).to.have.value('flights.airlines');
82+
expect(nameInput).to.have.value('airlines');
8283

8384
userEvent.click(screen.getByRole('textbox', { name: 'Notes' }));
8485
userEvent.type(
@@ -149,14 +150,14 @@ describe('DiagramEditorSidePanel', function () {
149150
result.plugin.store.dispatch(selectCollection('flights.airlines'));
150151

151152
await waitFor(() => {
152-
expect(screen.getByLabelText('Name')).to.have.value('flights.airlines');
153+
expect(screen.getByLabelText('Name')).to.have.value('airlines');
153154
});
154155

155156
result.plugin.store.dispatch(
156157
selectCollection('flights.airports_coordinates_for_schema')
157158
);
158159
expect(screen.getByLabelText('Name')).to.have.value(
159-
'flights.airports_coordinates_for_schema'
160+
'airports_coordinates_for_schema'
160161
);
161162

162163
result.plugin.store.dispatch(
@@ -178,15 +179,15 @@ describe('DiagramEditorSidePanel', function () {
178179
).to.be.visible;
179180

180181
result.plugin.store.dispatch(selectCollection('flights.planes'));
181-
expect(screen.getByLabelText('Name')).to.have.value('flights.planes');
182+
expect(screen.getByLabelText('Name')).to.have.value('planes');
182183
});
183184

184185
it('should open and edit relationship starting from collection', async function () {
185186
const result = renderDrawer();
186187
result.plugin.store.dispatch(selectCollection('flights.countries'));
187188

188189
await waitFor(() => {
189-
expect(screen.getByLabelText('Name')).to.have.value('flights.countries');
190+
expect(screen.getByLabelText('Name')).to.have.value('countries');
190191
});
191192

192193
// Open relationshipt editing form
@@ -249,7 +250,7 @@ describe('DiagramEditorSidePanel', function () {
249250
result.plugin.store.dispatch(selectCollection('flights.countries'));
250251

251252
await waitFor(() => {
252-
expect(screen.getByLabelText('Name')).to.have.value('flights.countries');
253+
expect(screen.getByLabelText('Name')).to.have.value('countries');
253254
});
254255

255256
// Find the relationhip item
@@ -270,4 +271,96 @@ describe('DiagramEditorSidePanel', function () {
270271
.exist;
271272
});
272273
});
274+
275+
it('should open and edit a collection name', async function () {
276+
const result = renderDrawer();
277+
result.plugin.store.dispatch(selectCollection('flights.countries'));
278+
279+
await waitFor(() => {
280+
expect(screen.getByLabelText('Name')).to.have.value('countries');
281+
});
282+
283+
// Update the name.
284+
userEvent.clear(screen.getByLabelText('Name'));
285+
userEvent.type(screen.getByLabelText('Name'), 'pineapple');
286+
287+
// Blur/unfocus the input.
288+
userEvent.click(document.body);
289+
290+
// Check the name in the model.
291+
const modifiedCollection = selectCurrentModelFromState(
292+
result.plugin.store.getState()
293+
).collections.find((c: DataModelCollection) => {
294+
return c.ns === 'flights.pineapple';
295+
});
296+
297+
expect(modifiedCollection).to.exist;
298+
});
299+
300+
it('should prevent editing to an empty collection name', async function () {
301+
const result = renderDrawer();
302+
result.plugin.store.dispatch(selectCollection('flights.countries'));
303+
304+
await waitFor(() => {
305+
expect(screen.getByLabelText('Name')).to.have.value('countries');
306+
expect(screen.getByLabelText('Name')).to.have.attribute(
307+
'aria-invalid',
308+
'false'
309+
);
310+
});
311+
312+
userEvent.clear(screen.getByLabelText('Name'));
313+
314+
await waitFor(() => {
315+
expect(screen.getByLabelText('Name')).to.have.attribute(
316+
'aria-invalid',
317+
'true'
318+
);
319+
});
320+
321+
// Blur/unfocus the input.
322+
userEvent.click(document.body);
323+
324+
const notModifiedCollection = selectCurrentModelFromState(
325+
result.plugin.store.getState()
326+
).collections.find((c: DataModelCollection) => {
327+
return c.ns === 'flights.countries';
328+
});
329+
330+
expect(notModifiedCollection).to.exist;
331+
});
332+
333+
it('should prevent editing to a duplicate collection name', async function () {
334+
const result = renderDrawer();
335+
result.plugin.store.dispatch(selectCollection('flights.countries'));
336+
337+
await waitFor(() => {
338+
expect(screen.getByLabelText('Name')).to.have.value('countries');
339+
expect(screen.getByLabelText('Name')).to.have.attribute(
340+
'aria-invalid',
341+
'false'
342+
);
343+
});
344+
345+
userEvent.clear(screen.getByLabelText('Name'));
346+
userEvent.type(screen.getByLabelText('Name'), 'airlines');
347+
348+
await waitFor(() => {
349+
expect(screen.getByLabelText('Name')).to.have.attribute(
350+
'aria-invalid',
351+
'true'
352+
);
353+
});
354+
355+
// Blur/unfocus the input.
356+
userEvent.click(document.body);
357+
358+
const notModifiedCollection = selectCurrentModelFromState(
359+
result.plugin.store.getState()
360+
).collections.find((c: DataModelCollection) => {
361+
return c.ns === 'flights.countries';
362+
});
363+
364+
expect(notModifiedCollection).to.exist;
365+
});
273366
});

packages/compass-data-modeling/src/services/data-model-storage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ const EditSchemaVariants = z.discriminatedUnion('type', [
6666
ns: z.string(),
6767
newPosition: z.tuple([z.number(), z.number()]),
6868
}),
69+
z.object({
70+
type: z.literal('RenameCollection'),
71+
fromNS: z.string(),
72+
toNS: z.string(),
73+
}),
6974
z.object({
7075
type: z.literal('UpdateCollectionNote'),
7176
ns: z.string(),

0 commit comments

Comments
 (0)