Skip to content

Commit cb934d1

Browse files
authored
fix(compass-data-modeling): visualise nested fields COMPASS-9488 (#7061)
1 parent d409119 commit cb934d1

File tree

2 files changed

+257
-22
lines changed

2 files changed

+257
-22
lines changed

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

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import {
44
createPluginTestHelpers,
55
screen,
66
waitFor,
7+
render,
8+
userEvent,
79
} from '@mongodb-js/testing-library-compass';
8-
import DiagramEditor from './diagram-editor';
10+
import DiagramEditor, { getFieldsFromSchema } from './diagram-editor';
911
import type { DataModelingStore } from '../../test/setup-store';
1012
import type {
1113
Edit,
@@ -229,3 +231,182 @@ describe('DiagramEditor', function () {
229231
});
230232
});
231233
});
234+
235+
describe('getFieldsFromSchema', function () {
236+
const validateMixedType = async (
237+
type: React.ReactNode,
238+
expectedTooltip: RegExp
239+
) => {
240+
render(<>{type}</>);
241+
const mixed = screen.getByText('(mixed)');
242+
expect(mixed).to.be.visible;
243+
expect(screen.queryByText(expectedTooltip)).to.not.exist;
244+
userEvent.hover(mixed);
245+
await waitFor(() => {
246+
expect(screen.getByText(expectedTooltip)).to.be.visible;
247+
});
248+
};
249+
250+
describe('flat schema', function () {
251+
it('return empty array for empty schema', function () {
252+
const result = getFieldsFromSchema({});
253+
expect(result).to.deep.equal([]);
254+
});
255+
256+
it('returns fields for a simple schema', function () {
257+
const result = getFieldsFromSchema({
258+
bsonType: 'object',
259+
properties: {
260+
name: { bsonType: 'string' },
261+
age: { bsonType: 'int' },
262+
},
263+
});
264+
expect(result).to.deep.equal([
265+
{ name: 'name', type: 'string', depth: 0, glyphs: [] },
266+
{ name: 'age', type: 'int', depth: 0, glyphs: [] },
267+
]);
268+
});
269+
270+
it('returns mixed fields with tooltip on hover', async function () {
271+
const result = getFieldsFromSchema({
272+
bsonType: 'object',
273+
properties: {
274+
age: { bsonType: ['int', 'string'] },
275+
},
276+
});
277+
expect(result[0]).to.deep.include({ name: 'age', depth: 0, glyphs: [] });
278+
await validateMixedType(result[0].type, /int, string/);
279+
});
280+
});
281+
282+
describe('nested schema', function () {
283+
it('returns fields for a nested schema', function () {
284+
const result = getFieldsFromSchema({
285+
bsonType: 'object',
286+
properties: {
287+
person: {
288+
bsonType: 'object',
289+
properties: {
290+
name: { bsonType: 'string' },
291+
address: {
292+
bsonType: 'object',
293+
properties: {
294+
street: { bsonType: 'string' },
295+
city: { bsonType: 'string' },
296+
},
297+
},
298+
},
299+
},
300+
},
301+
});
302+
expect(result).to.deep.equal([
303+
{ name: 'person', type: 'object', depth: 0, glyphs: [] },
304+
{ name: 'name', type: 'string', depth: 1, glyphs: [] },
305+
{ name: 'address', type: 'object', depth: 1, glyphs: [] },
306+
{ name: 'street', type: 'string', depth: 2, glyphs: [] },
307+
{ name: 'city', type: 'string', depth: 2, glyphs: [] },
308+
]);
309+
});
310+
311+
it('returns [] for array', function () {
312+
const result = getFieldsFromSchema({
313+
bsonType: 'object',
314+
properties: {
315+
tags: {
316+
bsonType: 'array',
317+
items: { bsonType: 'string' },
318+
},
319+
},
320+
});
321+
expect(result).to.deep.equal([
322+
{ name: 'tags', type: '[]', depth: 0, glyphs: [] },
323+
]);
324+
});
325+
326+
it('returns fields for an array of objects', function () {
327+
const result = getFieldsFromSchema({
328+
bsonType: 'object',
329+
properties: {
330+
todos: {
331+
bsonType: 'array',
332+
items: {
333+
bsonType: 'object',
334+
properties: {
335+
title: { bsonType: 'string' },
336+
completed: { bsonType: 'boolean' },
337+
},
338+
},
339+
},
340+
},
341+
});
342+
expect(result).to.deep.equal([
343+
{ name: 'todos', type: '[]', depth: 0, glyphs: [] },
344+
{ name: 'title', type: 'string', depth: 1, glyphs: [] },
345+
{ name: 'completed', type: 'boolean', depth: 1, glyphs: [] },
346+
]);
347+
});
348+
349+
it('returns fields for a mixed schema with objects', async function () {
350+
const result = getFieldsFromSchema({
351+
bsonType: 'object',
352+
properties: {
353+
name: {
354+
anyOf: [
355+
{ bsonType: 'string' },
356+
{
357+
bsonType: 'object',
358+
properties: {
359+
first: { bsonType: 'string' },
360+
last: { bsonType: 'string' },
361+
},
362+
},
363+
],
364+
},
365+
},
366+
});
367+
expect(result).to.have.lengthOf(3);
368+
expect(result[0]).to.deep.include({ name: 'name', depth: 0, glyphs: [] });
369+
await validateMixedType(result[0].type, /string, object/);
370+
expect(result[1]).to.deep.equal({
371+
name: 'first',
372+
type: 'string',
373+
depth: 1,
374+
glyphs: [],
375+
});
376+
expect(result[2]).to.deep.equal({
377+
name: 'last',
378+
type: 'string',
379+
depth: 1,
380+
glyphs: [],
381+
});
382+
});
383+
384+
it('returns fields for an array of mixed (including objects)', function () {
385+
const result = getFieldsFromSchema({
386+
bsonType: 'object',
387+
properties: {
388+
todos: {
389+
bsonType: 'array',
390+
items: {
391+
anyOf: [
392+
{
393+
bsonType: 'object',
394+
properties: {
395+
title: { bsonType: 'string' },
396+
completed: { bsonType: 'boolean' },
397+
},
398+
},
399+
{ bsonType: 'string' },
400+
],
401+
},
402+
},
403+
},
404+
});
405+
expect(result).to.deep.equal([
406+
{ name: 'todos', type: '[]', depth: 0, glyphs: [] },
407+
{ name: 'title', type: 'string', depth: 1, glyphs: [] },
408+
{ name: 'completed', type: 'boolean', depth: 1, glyphs: [] },
409+
]);
410+
});
411+
});
412+
});

packages/compass-data-modeling/src/components/diagram-editor.tsx

Lines changed: 75 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ import {
1818
Banner,
1919
Body,
2020
CancelLoader,
21-
Tooltip,
2221
WorkspaceContainer,
2322
css,
2423
spacing,
2524
Button,
2625
useDarkMode,
26+
InlineDefinition,
2727
} from '@mongodb-js/compass-components';
2828
import { cancelAnalysis, retryAnalysis } from '../store/analysis-process';
2929
import {
@@ -82,27 +82,90 @@ const ErrorBannerWithRetry: React.FunctionComponent<{
8282
);
8383
};
8484

85-
function getFieldTypeDisplay(field: MongoDBJSONSchema) {
86-
if (field.bsonType === undefined) {
85+
function getBsonTypeName(bsonType: string) {
86+
switch (bsonType) {
87+
case 'array':
88+
return '[]';
89+
default:
90+
return bsonType;
91+
}
92+
}
93+
94+
function getFieldTypeDisplay(bsonTypes: string[]) {
95+
if (bsonTypes.length === 0) {
8796
return 'unknown';
8897
}
8998

90-
if (typeof field.bsonType === 'string') {
91-
return field.bsonType;
99+
if (bsonTypes.length === 1) {
100+
return getBsonTypeName(bsonTypes[0]);
92101
}
93102

94-
const typesString = field.bsonType.join(', ');
103+
const typesString = bsonTypes
104+
.map((bsonType) => getBsonTypeName(bsonType))
105+
.join(', ');
95106

96107
// We show `mixed` with a tooltip when multiple bsonTypes were found.
97108
return (
98-
<Tooltip justify="end" spacing={5} trigger={<div>(mixed)</div>}>
99-
<Body className={mixedTypeTooltipContentStyles}>
100-
Multiple types found in sample: {typesString}
101-
</Body>
102-
</Tooltip>
109+
<InlineDefinition
110+
definition={
111+
<Body className={mixedTypeTooltipContentStyles}>
112+
Multiple types found in sample: {typesString}
113+
</Body>
114+
}
115+
>
116+
<div>(mixed)</div>
117+
</InlineDefinition>
103118
);
104119
}
105120

121+
export const getFieldsFromSchema = (
122+
jsonSchema: MongoDBJSONSchema,
123+
depth = 0
124+
): NodeProps['fields'] => {
125+
if (!jsonSchema || !jsonSchema.properties) {
126+
return [];
127+
}
128+
let fields: NodeProps['fields'] = [];
129+
for (const [name, field] of Object.entries(jsonSchema.properties)) {
130+
// field has types, properties and (optional) children
131+
// types are either direct, or from anyof
132+
// children are either direct (properties), from anyOf, items or items.anyOf
133+
const types: (string | string[])[] = [];
134+
const children = [];
135+
if (field.bsonType) types.push(field.bsonType);
136+
if (field.properties) children.push(field);
137+
if (field.items)
138+
children.push((field.items as MongoDBJSONSchema).anyOf || field.items);
139+
if (field.anyOf) {
140+
for (const variant of field.anyOf) {
141+
if (variant.bsonType) types.push(variant.bsonType);
142+
if (variant.properties) {
143+
children.push(variant);
144+
}
145+
if (variant.items) children.push(variant.items);
146+
}
147+
}
148+
149+
fields.push({
150+
name,
151+
type: getFieldTypeDisplay(types.flat()),
152+
depth: depth,
153+
glyphs: types.length === 1 && types[0] === 'objectId' ? ['key'] : [],
154+
});
155+
156+
if (children.length > 0) {
157+
fields = [
158+
...fields,
159+
...children
160+
.flat()
161+
.flatMap((child) => getFieldsFromSchema(child, depth + 1)),
162+
];
163+
}
164+
}
165+
166+
return fields;
167+
};
168+
106169
const modelPreviewContainerStyles = css({
107170
display: 'grid',
108171
gridTemplateColumns: '100%',
@@ -173,16 +236,7 @@ const DiagramEditor: React.FunctionComponent<{
173236
y: coll.displayPosition[1],
174237
},
175238
title: coll.ns,
176-
fields: Object.entries(coll.jsonSchema.properties ?? {}).map(
177-
([name, field]) => {
178-
const type = getFieldTypeDisplay(field);
179-
return {
180-
name,
181-
type,
182-
glyphs: type === 'objectId' ? ['key'] : [],
183-
};
184-
}
185-
),
239+
fields: getFieldsFromSchema(coll.jsonSchema),
186240
})
187241
);
188242
}, [model?.collections]);

0 commit comments

Comments
 (0)