- Schema Editor Content Placeholder
+
+
+
+ Confirm Field to Faker Function Mappings
+
+
+ We have sampled your collection and created a schema based on your
+ documents. That schema has been sent to an LLM and it has returned the
+ following mapping between your schema fields and{' '}
+ faker functions
+ .
+
+
+ {fakerSchemaGenerationState.status === 'in-progress' && (
+
+
+
+ )}
+ {fakerSchemaGenerationState.status === 'completed' && (
+
+ )}
);
};
diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx
index 7fec9d737fd..2e313aaaefc 100644
--- a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx
+++ b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx
@@ -15,6 +15,7 @@ import { StepButtonLabelMap } from './constants';
import type { CollectionState } from '../../modules/collection-tab';
import { default as collectionTabReducer } from '../../modules/collection-tab';
import type { ConnectionInfo } from '@mongodb-js/connection-info';
+import type { MockDataSchemaResponse } from '@mongodb-js/compass-generative-ai';
describe('MockDataGeneratorModal', () => {
async function renderModal({
@@ -80,10 +81,19 @@ describe('MockDataGeneratorModal', () => {
atlasAiService: {
getMockDataSchema: () => {
return Promise.resolve({
- contents: {
- fields: [],
+ content: {
+ fields: [
+ {
+ fieldPath: 'name',
+ mongoType: 'string',
+ fakerMethod: 'person.firstName',
+ fakerArgs: [],
+ isArray: false,
+ probability: 1.0,
+ },
+ ],
},
- });
+ } as MockDataSchemaResponse);
},
},
workspaces: {},
@@ -175,7 +185,7 @@ describe('MockDataGeneratorModal', () => {
userEvent.click(screen.getByText('Confirm'));
await waitFor(() => {
- expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ expect(screen.getByTestId('faker-schema-editor-loader')).to.exist;
});
userEvent.click(screen.getByText('Cancel'));
@@ -191,7 +201,7 @@ describe('MockDataGeneratorModal', () => {
userEvent.click(screen.getByText('Confirm'));
await waitFor(() => {
- expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ expect(screen.getByTestId('faker-schema-editor-loader')).to.exist;
});
userEvent.click(screen.getByText('Back'));
@@ -273,6 +283,214 @@ describe('MockDataGeneratorModal', () => {
});
});
+ describe('on the schema editor step', () => {
+ const mockServicesWithMockDataResponse = createMockServices();
+ mockServicesWithMockDataResponse.atlasAiService.getMockDataSchema = () =>
+ Promise.resolve({
+ content: {
+ fields: [
+ {
+ fieldPath: 'name',
+ mongoType: 'string',
+ fakerMethod: 'person.firstName',
+ fakerArgs: [],
+ isArray: false,
+ probability: 1.0,
+ },
+ {
+ fieldPath: 'age',
+ mongoType: 'int',
+ fakerMethod: 'number.int',
+ fakerArgs: [],
+ isArray: false,
+ probability: 1.0,
+ },
+ {
+ fieldPath: 'email',
+ mongoType: 'string',
+ fakerMethod: 'internet',
+ fakerArgs: [],
+ isArray: false,
+ probability: 1.0,
+ },
+ {
+ fieldPath: 'username',
+ mongoType: 'string',
+ fakerMethod: 'noSuchMethod',
+ fakerArgs: [],
+ isArray: false,
+ probability: 1.0,
+ },
+ ],
+ },
+ });
+
+ it('shows a loading spinner when the faker schema generation is in progress', async () => {
+ const mockServices = createMockServices();
+ mockServices.atlasAiService.getMockDataSchema = () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ content: {
+ fields: [],
+ },
+ }),
+ 1000
+ )
+ );
+
+ await renderModal();
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+ expect(screen.getByTestId('faker-schema-editor-loader')).to.exist;
+ });
+
+ it('shows the faker schema editor when the faker schema generation is completed', async () => {
+ await renderModal({ mockServices: mockServicesWithMockDataResponse });
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+
+ expect(await screen.findByTestId('faker-schema-editor')).to.exist;
+ expect(screen.getByText('name')).to.exist;
+ expect(screen.getByText('age')).to.exist;
+ });
+
+ it('shows correct values for the faker schema editor', async () => {
+ await renderModal({ mockServices: mockServicesWithMockDataResponse });
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+ // the "name" field should be selected by default
+ expect(screen.getByText('name')).to.exist;
+ expect(screen.getByLabelText('JSON Type')).to.have.value('string');
+ expect(screen.getByLabelText('Faker Function')).to.have.value(
+ 'person.firstName'
+ );
+ // select the "age" field
+ userEvent.click(screen.getByText('age'));
+ expect(screen.getByText('age')).to.exist;
+ expect(screen.getByLabelText('JSON Type')).to.have.value('int');
+ expect(screen.getByLabelText('Faker Function')).to.have.value(
+ 'number.int'
+ );
+ // select the "email" field
+ userEvent.click(screen.getByText('email'));
+ expect(screen.getByText('email')).to.exist;
+ expect(screen.getByLabelText('JSON Type')).to.have.value('string');
+ // the "email" field should have a warning banner since the faker method is invalid
+ expect(screen.getByLabelText('Faker Function')).to.have.value(
+ 'Unrecognized'
+ );
+ expect(
+ screen.getByText(
+ 'Please select a function or we will default fill this field with the string "Unrecognized"'
+ )
+ ).to.exist;
+
+ // select the "username" field
+ userEvent.click(screen.getByText('username'));
+ expect(screen.getByText('username')).to.exist;
+ expect(screen.getByLabelText('JSON Type')).to.have.value('string');
+ expect(screen.getByLabelText('Faker Function')).to.have.value(
+ 'Unrecognized'
+ );
+ });
+
+ it('disables the Next button when the faker schema mapping is not confirmed', async () => {
+ await renderModal({
+ mockServices: mockServicesWithMockDataResponse,
+ });
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('true');
+ });
+
+ it('resets the confirm schema mapping state when the user clicks the back button then goes back to the schema editor step', async () => {
+ await renderModal({
+ mockServices: mockServicesWithMockDataResponse,
+ });
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('true');
+ // click confirm mappings button
+ userEvent.click(screen.getByText('Confirm mappings'));
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('false');
+
+ // click back button
+ userEvent.click(screen.getByText('Back'));
+ await waitFor(() => {
+ expect(screen.getByTestId('raw-schema-confirmation')).to.exist;
+ });
+
+ // click next button to advance to the schema editor step again
+ userEvent.click(screen.getByTestId('next-step-button'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+ // the next button should be disabled again
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('true');
+ });
+
+ it('preserves the confirm schema mapping state when the user clicks the next button then goes back to the schema editor step', async () => {
+ await renderModal({
+ mockServices: mockServicesWithMockDataResponse,
+ });
+
+ // advance to the schema editor step
+ userEvent.click(screen.getByText('Confirm'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('true');
+ // click confirm mappings button
+ userEvent.click(screen.getByText('Confirm mappings'));
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('false');
+
+ // click next button
+ userEvent.click(screen.getByTestId('next-step-button'));
+ await waitFor(() => {
+ expect(screen.queryByTestId('faker-schema-editor')).to.not.exist;
+ });
+
+ // click back button to go back to the schema editor step
+ userEvent.click(screen.getByText('Back'));
+ await waitFor(() => {
+ expect(screen.getByTestId('faker-schema-editor')).to.exist;
+ });
+ // the next button should not be disabled
+ expect(
+ screen.getByTestId('next-step-button').getAttribute('aria-disabled')
+ ).to.equal('false');
+ });
+ });
+
describe('on the generate data step', () => {
it('enables the Back button', async () => {
await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA });
diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx
index 5f6982de0cb..e2aa91a67a8 100644
--- a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx
+++ b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx
@@ -13,7 +13,7 @@ import {
spacing,
} from '@mongodb-js/compass-components';
-import { MockDataGeneratorStep } from './types';
+import { type MockDataGeneratorState, MockDataGeneratorStep } from './types';
import { StepButtonLabelMap } from './constants';
import type { CollectionState } from '../../modules/collection-tab';
import {
@@ -50,6 +50,7 @@ interface Props {
onConfirmSchema: () => Promise
;
onPreviousStep: () => void;
namespace: string;
+ fakerSchemaGenerationState: MockDataGeneratorState;
}
const MockDataGeneratorModal = ({
@@ -60,13 +61,23 @@ const MockDataGeneratorModal = ({
onConfirmSchema,
onPreviousStep,
namespace,
+ fakerSchemaGenerationState,
}: Props) => {
+ const [isSchemaConfirmed, setIsSchemaConfirmed] =
+ React.useState(false);
+
const modalBodyContent = useMemo(() => {
switch (currentStep) {
case MockDataGeneratorStep.SCHEMA_CONFIRMATION:
return ;
case MockDataGeneratorStep.SCHEMA_EDITOR:
- return ;
+ return (
+
+ );
case MockDataGeneratorStep.DOCUMENT_COUNT:
return <>>; // TODO: CLOUDP-333856
case MockDataGeneratorStep.PREVIEW_DATA:
@@ -74,7 +85,10 @@ const MockDataGeneratorModal = ({
case MockDataGeneratorStep.GENERATE_DATA:
return ;
}
- }, [currentStep]);
+ }, [currentStep, fakerSchemaGenerationState, isSchemaConfirmed]);
+
+ const isNextButtonDisabled =
+ currentStep === MockDataGeneratorStep.SCHEMA_EDITOR && !isSchemaConfirmed;
const handleNextClick = () => {
if (currentStep === MockDataGeneratorStep.GENERATE_DATA) {
@@ -89,6 +103,14 @@ const MockDataGeneratorModal = ({
const shouldShowNamespace =
currentStep !== MockDataGeneratorStep.GENERATE_DATA;
+ const handlePreviousClick = () => {
+ if (currentStep === MockDataGeneratorStep.SCHEMA_EDITOR) {
+ // reset isSchemaConfirmed state when previous step is clicked
+ setIsSchemaConfirmed(false);
+ }
+ onPreviousStep();
+ };
+
return (
Back
@@ -122,6 +144,7 @@ const MockDataGeneratorModal = ({
variant={ButtonVariant.Primary}
onClick={handleNextClick}
data-testid="next-step-button"
+ disabled={isNextButtonDisabled}
>
{StepButtonLabelMap[currentStep]}
@@ -135,6 +158,7 @@ const mapStateToProps = (state: CollectionState) => ({
isOpen: state.mockDataGenerator.isModalOpen,
currentStep: state.mockDataGenerator.currentStep,
namespace: state.namespace,
+ fakerSchemaGenerationState: state.fakerSchemaGeneration,
});
const ConnectedMockDataGeneratorModal = connect(mapStateToProps, {
diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx
new file mode 100644
index 00000000000..ccfeff58482
--- /dev/null
+++ b/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import {
+ css,
+ cx,
+ spacing,
+ palette,
+ useDarkMode,
+ Body,
+} from '@mongodb-js/compass-components';
+
+const fieldsContainerStyles = css({
+ width: '40%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing[100],
+});
+
+const fieldSelectorStyles = css({
+ maxHeight: '300px',
+ overflow: 'auto',
+});
+
+const buttonStyles = css({
+ borderRadius: spacing[100],
+ cursor: 'pointer',
+ marginBottom: spacing[100],
+ background: 'none',
+ border: 'none',
+ width: '100%',
+ padding: spacing[200],
+ textAlign: 'left',
+ fontWeight: 500,
+});
+
+const activeStylesLight = css({
+ color: palette.green.dark2,
+ backgroundColor: palette.green.light3,
+ fontWeight: 600,
+
+ '&:active,&:focus': {
+ backgroundColor: palette.green.light3,
+ },
+});
+
+const activeStylesDark = css({
+ color: palette.white,
+ '&:active,&:focus': {
+ backgroundColor: palette.gray.dark3,
+ color: palette.white,
+ },
+});
+
+const hoverStylesLight = css({
+ '&:hover,&:focus': {
+ backgroundColor: palette.gray.light2,
+ color: palette.black,
+ },
+});
+
+const hoverStylesDark = css({
+ '&:hover,&:focus': {
+ backgroundColor: palette.gray.dark3,
+ color: palette.gray.light2,
+ },
+});
+
+const labelStyles = css({
+ color: palette.gray.dark1,
+ fontWeight: 600,
+});
+
+type SidebarProps = {
+ activeField: string;
+ onFieldSelect: (field: string) => void;
+ fields: Array;
+};
+
+const FieldSelector: React.FunctionComponent = ({
+ activeField,
+ fields,
+ onFieldSelect,
+}) => {
+ const darkMode = useDarkMode();
+
+ return (
+
+ Document Fields
+
+ {fields.map((field) => (
+ onFieldSelect(field)}
+ >
+ {field}
+
+ ))}
+
+
+ );
+};
+
+export default FieldSelector;
diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/types.ts b/packages/compass-collection/src/components/mock-data-generator-modal/types.ts
index 5812f3693a4..279bc3f3921 100644
--- a/packages/compass-collection/src/components/mock-data-generator-modal/types.ts
+++ b/packages/compass-collection/src/components/mock-data-generator-modal/types.ts
@@ -19,7 +19,7 @@ type MockDataGeneratorInProgressState = {
type MockDataGeneratorCompletedState = {
status: 'completed';
- fakerSchema: MockDataSchemaResponse;
+ fakerSchema: FakerSchemaMapping[];
requestId: string;
};
@@ -34,3 +34,8 @@ export type MockDataGeneratorState =
| MockDataGeneratorInProgressState
| MockDataGeneratorCompletedState
| MockDataGeneratorErrorState;
+
+export type FakerSchemaMapping = Omit<
+ MockDataSchemaResponse['content']['fields'][number],
+ 'isArray'
+>;
diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts
index 8c7323045f6..449e4b503ab 100644
--- a/packages/compass-collection/src/modules/collection-tab.ts
+++ b/packages/compass-collection/src/modules/collection-tab.ts
@@ -38,7 +38,13 @@ import {
} from '../transform-schema-to-field-info';
import type { Document, MongoError } from 'mongodb';
import { MockDataGeneratorStep } from '../components/mock-data-generator-modal/types';
-import type { MockDataGeneratorState } from '../components/mock-data-generator-modal/types';
+import type {
+ FakerSchemaMapping,
+ MockDataGeneratorState,
+} from '../components/mock-data-generator-modal/types';
+
+// @ts-expect-error TypeScript warns us about importing ESM module from CommonJS module, but we can ignore since this code will be consumed by webpack.
+import { faker } from '@faker-js/faker/locale/en';
const DEFAULT_SAMPLE_SIZE = 100;
@@ -52,6 +58,7 @@ function isAction(
}
const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50;
+export const UNRECOGNIZED_FAKER_METHOD = 'Unrecognized';
function getErrorDetails(error: Error): SchemaAnalysisError {
if (error instanceof ProcessSchemaUnsupportedStateError) {
@@ -178,7 +185,7 @@ export interface FakerMappingGenerationStartedAction {
export interface FakerMappingGenerationCompletedAction {
type: CollectionActions.FakerMappingGenerationCompleted;
- fakerSchema: MockDataSchemaResponse;
+ fakerSchema: FakerSchemaMapping[];
requestId: string;
}
@@ -692,6 +699,34 @@ export const cancelSchemaAnalysis = (): CollectionThunkAction => {
};
};
+const validateFakerSchema = (
+ fakerSchema: MockDataSchemaResponse,
+ logger: Logger
+) => {
+ return fakerSchema.content.fields.map((field) => {
+ const { fakerMethod } = field;
+
+ const [moduleName, methodName, ...rest] = fakerMethod.split('.');
+ if (
+ rest.length > 0 ||
+ typeof (faker as any)[moduleName]?.[methodName] !== 'function'
+ ) {
+ logger.log.warn(
+ mongoLogId(1_001_000_372),
+ 'Collection',
+ 'Invalid faker method',
+ { fakerMethod }
+ );
+ return {
+ ...field,
+ fakerMethod: UNRECOGNIZED_FAKER_METHOD,
+ };
+ }
+
+ return field;
+ });
+};
+
export const generateFakerMappings = (): CollectionThunkAction<
Promise
> => {
@@ -758,10 +793,12 @@ export const generateFakerMappings = (): CollectionThunkAction<
connectionInfoRef.current
);
+ const validatedFakerSchema = validateFakerSchema(response, logger);
+
fakerSchemaGenerationAbortControllerRef.current = undefined;
dispatch({
type: CollectionActions.FakerMappingGenerationCompleted,
- fakerSchema: response,
+ fakerSchema: validatedFakerSchema,
requestId: requestId,
});
} catch (e) {
diff --git a/packages/compass-web/webpack.config.js b/packages/compass-web/webpack.config.js
index c76ff5cbc92..4302f432967 100644
--- a/packages/compass-web/webpack.config.js
+++ b/packages/compass-web/webpack.config.js
@@ -325,6 +325,8 @@ module.exports = (env, args) => {
// bson is not that big, but is a shared dependency of compass-web,
// compass-components and bson-transpilers, so splitting it out
'bson',
+ // dependency of compass-collection
+ '@faker-js/faker',
]);
return bundles;