diff --git a/packages/compass-collection/src/stores/index.ts b/packages/compass-collection/src/stores/index.ts index 823c14e3ebe..f4c345e6f3e 100644 --- a/packages/compass-collection/src/stores/index.ts +++ b/packages/compass-collection/src/stores/index.ts @@ -318,21 +318,6 @@ store.onActivated = (appRegistry: AppRegistry) => { // TODO: importing hadron-ipc in unit tests doesn't work right now if (ipc.on) { - /** - * When `Share Schema as JSON` clicked in menu send event to the active tab. - */ - ipc.on('window:menu-share-schema-json', () => { - const state = store.getState(); - if (state.tabs) { - const activeTab = state.tabs.find( - (tab: WorkspaceTabObject) => tab.isActive === true - ); - if (activeTab.localAppRegistry) { - activeTab.localAppRegistry.emit('menu-share-schema-json'); - } - } - }); - ipc.on('compass:open-export', () => { const state = store.getState(); if (!state.tabs) { diff --git a/packages/compass-schema/src/actions/index.js b/packages/compass-schema/src/actions/index.js index 55e7ba6d449..c34f263589d 100644 --- a/packages/compass-schema/src/actions/index.js +++ b/packages/compass-schema/src/actions/index.js @@ -34,6 +34,8 @@ const configureActions = () => { geoLayerAdded: { sync: true }, geoLayersEdited: { sync: true }, geoLayersDeleted: { sync: true }, + openExportSchema: { sync: true }, + closeExportSchema: { sync: true }, }); }; diff --git a/packages/compass-schema/src/components/compass-schema.tsx b/packages/compass-schema/src/components/compass-schema.tsx index bc3d5a63662..22f95c2d32e 100644 --- a/packages/compass-schema/src/components/compass-schema.tsx +++ b/packages/compass-schema/src/components/compass-schema.tsx @@ -24,7 +24,10 @@ import { useDarkMode, WorkspaceContainer, lighten, + ConfirmationModal, + InfoModal, } from '@mongodb-js/compass-components'; +import { ExportSchemaModal } from './export-schema/export-schema-modal'; const rootStyles = css` width: 100%; @@ -329,6 +332,7 @@ const Schema: React.FunctionComponent<{ schema?: any; count?: number; resultId?: string; + exportSchemaOpened?: boolean; }> = ({ actions, store, @@ -338,6 +342,7 @@ const Schema: React.FunctionComponent<{ errorMessage, schema, resultId, + exportSchemaOpened, }) => { useEffect(() => { if (isActiveTab) { @@ -357,6 +362,10 @@ const Schema: React.FunctionComponent<{ actions.startAnalysis(); }, [actions]); + const onExportSchemaClicked = useCallback(() => { + actions.openExportSchema(); + }, [actions]); + return (
)} + +
); }; diff --git a/packages/compass-schema/src/components/export-schema/export-schema-modal-action-button.tsx b/packages/compass-schema/src/components/export-schema/export-schema-modal-action-button.tsx new file mode 100644 index 00000000000..be67ad53524 --- /dev/null +++ b/packages/compass-schema/src/components/export-schema/export-schema-modal-action-button.tsx @@ -0,0 +1,118 @@ +import { Button, css, cx, Icon } from '@mongodb-js/compass-components'; +import React, { useCallback, useEffect, useState } from 'react'; + +const actionButtonStyle = css({ + flex: 'none', +}); + +const actionButtonContentStyle = css({ + position: 'relative', +}); + +const actionButtonIconContainerStyle = css({ + opacity: 1, + // leafygreen icon small size + width: 14, + height: 14, + transition: 'opacity .2s linear', +}); + +const actionButtonIconContainerHiddenStyle = css({ + opacity: 0, +}); + +const actionButtonClickResultIconStyle = css({ + position: 'absolute', + // leafygreen icon small size + width: 14, + height: 14, + top: 0, + pointerEvents: 'none', + opacity: 1, + transition: 'opacity .2s linear', +}); + +const actionButtonClickResultIconHiddenStyle = css({ + opacity: 0, +}); + +const ActionButton: React.FunctionComponent<{ + icon: string | React.ReactNode; + label: string; + onClick: ( + ...args: Parameters> + ) => boolean | void; +}> = ({ label, icon, onClick }) => { + const [clickResult, setClickResult] = useState<'success' | 'error'>( + 'success' + ); + const [clickResultVisible, setClickResultVisible] = useState(false); + + const onButtonClick = useCallback( + (...args: Parameters>) => { + const result = onClick(...args); + if (typeof result === 'boolean') { + setClickResult(result ? 'success' : 'error'); + setClickResultVisible(true); + } + }, + [onClick] + ); + + useEffect(() => { + if (!clickResultVisible) { + return; + } + + const timeoutId = setTimeout(() => { + setClickResultVisible(false); + }, 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [clickResultVisible]); + + return ( + + ); +}; + +export { ActionButton }; diff --git a/packages/compass-schema/src/components/export-schema/export-schema-modal.tsx b/packages/compass-schema/src/components/export-schema/export-schema-modal.tsx new file mode 100644 index 00000000000..3bcb8e88bff --- /dev/null +++ b/packages/compass-schema/src/components/export-schema/export-schema-modal.tsx @@ -0,0 +1,321 @@ +import { + Banner, + BannerVariant, + Checkbox, + css, + InfoModal, + Option, + Select, + spacing, + TextInput, + WorkspaceContainer, +} from '@mongodb-js/compass-components'; +import type { Schema } from 'mongodb-schema'; +import React, { useMemo, useState, useCallback } from 'react'; + +import { JSONEditor } from '@mongodb-js/compass-editor'; +import { ActionButton } from './export-schema-modal-action-button'; +import { exportMongodbJSONSchema } from './formats/mongodb-json-schema'; +import { exportCompassInternalSchema } from './formats/compass-internal-schema'; +import { exportJSONSchema } from './formats/ejson-json-schema'; + +type ExportSchemaModalProps = { + schema: Schema; + closeExportSchema: () => void; + exportSchemaOpened: boolean; +}; + +type Export = { code?: string; error?: string }; + +type ExportPanelProps = { settings?: JSX.Element; exportInfo: Export }; + +const exportPanelStyles = css({ + display: 'grid', + gridTemplateColumns: `1fr 1fr`, + gridTemplateRows: '1fr', + gap: spacing[2], + maxHeight: '100%', + position: 'relative', +}); + +const exportPanelCodeAreaStyles = css({ + position: 'relative', +}); + +const editorStyles = css({ + width: spacing[6] * 10, + height: spacing[6] * 7, +}); + +const exportPanelCopyButtonStyles = css({ + position: 'absolute', + top: spacing[2], + right: spacing[3], +}); + +const exportPanelSettingsAreaStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[2], +}); + +const ExportPanel: React.FunctionComponent = ({ + settings, + exportInfo, +}) => { + const handleCopy = useCallback(() => { + if (exportInfo.code) { + void navigator.clipboard.writeText(exportInfo.code); + return true; + } + + return false; + }, [exportInfo.code]); + + return ( +
+
+ {exportInfo.error ? ( + + Error: {exportInfo.error} + + ) : ( + <> + + + +
+ +
+ + )} +
+ + {settings && ( +
{settings}
+ )} +
+ ); +}; + +function useSchemaConversion( + schema: Schema, + settings: T, + converter: (schema: Schema, settings: T) => string +): Export { + const exportInfo = useMemo(() => { + try { + return { + code: converter(schema, settings), + }; + } catch (e) { + const errorMessage = (e as Error)?.message || String(e); + return { error: `Conversion failed: ${errorMessage}` }; + } + }, [converter, schema, ...(settings && Object.entries(settings).flat())]); + + return exportInfo; +} + +const MongodbJsonSchema: React.FunctionComponent<{ schema: Schema }> = ({ + schema, +}) => { + const [includeId, setIncludeId] = useState(true); + const [additionalProperties, setAdditionalProperties] = + useState(true); + const [requireMandatoryProperties, setRequireMandatoryProperties] = + useState(true); + + const exportInfo: Export = useSchemaConversion( + schema, + { includeId, requireMandatoryProperties, additionalProperties }, + (schema, settings) => + JSON.stringify(exportMongodbJSONSchema(schema, settings), null, 2) + ); + + return ( + + Include _id} + onChange={(event) => setIncludeId(event.target.checked)} + checked={includeId} + /> + setAdditionalProperties(event.target.checked)} + checked={additionalProperties} + /> + + setRequireMandatoryProperties(event.target.checked) + } + checked={requireMandatoryProperties} + /> + + } + /> + ); +}; + +const JsonSchema: React.FunctionComponent<{ schema: Schema }> = ({ + schema, +}) => { + const [includeId, setIncludeId] = useState(false); + const [additionalProperties, setAdditionalProperties] = + useState(true); + const [requireMandatoryProperties, setRequireMandatoryProperties] = + useState(true); + + const [relaxed, setRelaxed] = useState(true); + + const exportInfo: Export = useSchemaConversion( + schema, + { includeId, requireMandatoryProperties, additionalProperties, relaxed }, + (schema, settings) => + JSON.stringify(exportJSONSchema(schema, settings), null, 2) + ); + + return ( + + setIncludeId(event.target.checked)} + checked={includeId} + /> + setAdditionalProperties(event.target.checked)} + checked={additionalProperties} + /> + + setRequireMandatoryProperties(event.target.checked) + } + checked={requireMandatoryProperties} + /> + + + } + /> + ); +}; + +const CompassInternalSchema: React.FunctionComponent<{ schema: Schema }> = ({ + schema, +}) => { + const [maxValues, setMaxValues] = useState(3); + + const exportInfo: Export = useSchemaConversion( + schema, + { maxValues }, + (schema, settings) => + JSON.stringify(exportCompassInternalSchema(schema, settings), null, 2) + ); + + return ( + + setMaxValues(+event.target.value)} + value={String(maxValues)} + /> + + } + /> + ); +}; + +const exportSchemaModalGridStyles = css({ + display: 'grid', + gridTemplateRows: 'auto 1fr', + gap: spacing[2], + height: '100%', +}); + +type ExportFormat = 'mongodbJsonSchema' | 'internalSchema' | 'jsonSchema'; + +const ExportSchemaModal: React.FunctionComponent = ({ + schema, + closeExportSchema, + exportSchemaOpened, +}) => { + const [schemaType, setSchemaType] = + useState('mongodbJsonSchema'); + + return ( + +
+ + +
+ {schemaType === 'internalSchema' && ( + + )} + {schemaType === 'mongodbJsonSchema' && ( + + )} + {schemaType === 'jsonSchema' && } +
+
+
+ ); +}; + +export { ExportSchemaModal }; diff --git a/packages/compass-schema/src/components/export-schema/formats/compass-internal-schema.ts b/packages/compass-schema/src/components/export-schema/formats/compass-internal-schema.ts new file mode 100644 index 00000000000..0d1a8ce1339 --- /dev/null +++ b/packages/compass-schema/src/components/export-schema/formats/compass-internal-schema.ts @@ -0,0 +1,77 @@ +import type { Schema } from 'mongodb-schema'; +import type { + ArraySchemaType, + DocumentSchemaType, + PrimitiveSchemaType, + SchemaField, + SchemaType, +} from 'mongodb-schema/lib/stream'; +import _ from 'lodash'; + +type Settings = { maxValues: number }; + +export function exportCompassInternalSchema( + schema: Schema, + settings: Settings +): Schema { + const { maxValues } = settings; + function slicePrimitiveSchemaType( + type: PrimitiveSchemaType + ): PrimitiveSchemaType { + return { + ...type, + values: + maxValues === 0 + ? (undefined as any) + : _.uniq(type.values.slice(0, maxValues)), + }; + } + + function sliceArraySchemaType(type: ArraySchemaType): ArraySchemaType { + const slicedTypes = type.types.map(sliceSchemaType); + const slicedLengths = _.uniq(type.lengths.slice(0, maxValues)); + return { + ...type, + types: slicedTypes, + lengths: maxValues === 0 ? (undefined as any) : slicedLengths, + }; + } + + function sliceDocumentSchemaType( + type: DocumentSchemaType + ): DocumentSchemaType { + const slicedFields = type.fields.map(sliceSchemaField); + return { + ...type, + fields: slicedFields, + }; + } + + function sliceSchemaType(type: SchemaType): SchemaType { + switch (type.name) { + case 'Null': + case 'Undefined': + return type; + case 'Array': + return sliceArraySchemaType(type); + case 'Document': + return sliceDocumentSchemaType(type); + default: + return slicePrimitiveSchemaType(type); + } + } + + function sliceSchemaField(field: SchemaField): SchemaField { + const slicedTypes = field.types.map(sliceSchemaType); + return { + ...field, + types: slicedTypes, + }; + } + + const slicedFields = schema.fields.map(sliceSchemaField); + return { + ...schema, + fields: slicedFields, + }; +} diff --git a/packages/compass-schema/src/components/export-schema/formats/ejson-json-schema.ts b/packages/compass-schema/src/components/export-schema/formats/ejson-json-schema.ts new file mode 100644 index 00000000000..067ab6c1b7d --- /dev/null +++ b/packages/compass-schema/src/components/export-schema/formats/ejson-json-schema.ts @@ -0,0 +1,400 @@ +import type { + Schema, + SchemaField, + SchemaType, +} from 'mongodb-schema/lib/stream'; + +export const CANONICAL = Object.freeze({ + ObjectId: { + type: 'object', + properties: { + $oid: { + type: 'string', + pattern: '^[0-9a-fA-F]{24}$', + }, + }, + required: ['$oid'], + additionalProperties: false, + }, + BSONSymbol: { + type: 'object', + properties: { + $symbol: { + type: 'string', + }, + }, + required: ['$symbol'], + additionalProperties: false, + }, + Int32: { + type: 'object', + properties: { + $numberInt: { + type: 'string', + pattern: '^-?[0-9]{1,10}$', + }, + }, + required: ['$numberInt'], + additionalProperties: false, + }, + Long: { + type: 'object', + properties: { + $numberLong: { + type: 'string', + pattern: '^-?[0-9]{1,19}$', + }, + }, + required: ['$numberLong'], + additionalProperties: false, + }, + Double: { + type: 'object', + properties: { + $numberDouble: { + oneOf: [ + { + type: 'string', + pattern: '^(?:-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)$', + }, + { + type: 'string', + enum: ['Infinity', '-Infinity', 'NaN'], + }, + ], + }, + }, + required: ['$numberDouble'], + additionalProperties: false, + }, + Decimal128: { + type: 'object', + properties: { + $numberDecimal: { + type: 'string', + }, + }, + required: ['$numberDecimal'], + additionalProperties: false, + }, + Binary: { + type: 'object', + properties: { + $binary: { + type: 'object', + properties: { + base64: { + type: 'string', + }, + subType: { + type: 'string', + pattern: '^[0-9a-fA-F]{2}$', + }, + }, + required: ['base64', 'subType'], + additionalProperties: false, + }, + }, + required: ['$binary'], + additionalProperties: false, + }, + Code: { + type: 'object', + properties: { + $code: { + type: 'string', + }, + }, + required: ['$code'], + additionalProperties: false, + }, + Timestamp: { + type: 'object', + properties: { + $timestamp: { + type: 'object', + properties: { + t: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + }, + i: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + }, + }, + required: ['t', 'i'], + additionalProperties: false, + }, + }, + required: ['$timestamp'], + additionalProperties: false, + }, + RegExp: { + type: 'object', + properties: { + $regularExpression: { + type: 'object', + properties: { + pattern: { + type: 'string', + }, + options: { + type: 'string', + pattern: '^[gimuy]*$', + }, + }, + required: ['pattern'], + additionalProperties: false, + }, + }, + required: ['$regularExpression'], + additionalProperties: false, + }, + DBPointer: { + type: 'object', + properties: { + $dbPointer: { + type: 'object', + properties: { + $ref: { + type: 'string', + }, + $id: { + type: 'object', + }, + }, + required: ['$ref', '$id'], + additionalProperties: false, + }, + }, + required: ['$dbPointer'], + additionalProperties: false, + }, + Date: { + type: 'object', + properties: { + $date: { + type: 'object', + properties: { + $numberLong: { + type: 'string', + pattern: '^-?[0-9]{1,19}$', + }, + }, + required: ['$numberLong'], + additionalProperties: false, + }, + }, + required: ['$date'], + additionalProperties: false, + }, + DBRef: { + type: 'object', + properties: { + $ref: { + type: 'string', + }, + $id: {}, + $db: { + type: 'string', + }, + }, + required: ['$ref', '$id'], + additionalProperties: true, + }, + MinKey: { + type: 'object', + properties: { + $minKey: { + type: 'integer', + const: 1, + }, + }, + required: ['$minKey'], + additionalProperties: false, + }, + MaxKey: { + type: 'object', + properties: { + $maxKey: { + type: 'integer', + const: 1, + }, + }, + required: ['$maxKey'], + additionalProperties: false, + }, + Undefined: { + type: 'object', + properties: { + $undefined: { + type: 'boolean', + const: true, + }, + }, + required: ['$undefined'], + additionalProperties: false, + }, +}); + +export const RELAXED = Object.freeze({ + ...CANONICAL, + Int32: { + type: 'integer', + }, + Long: { + type: 'integer', + }, + Double: { + oneOf: [ + { type: 'number' }, + { + enum: ['Infinity', '-Infinity', 'NaN'], + }, + ], + }, + Date: { + type: 'object', + properties: { + $date: { + type: 'string', + format: 'date-time', + }, + }, + required: ['$date'], + additionalProperties: false, + }, +}); + +type SimpleSchemaType = + | Exclude + | 'Double' + | 'BSONSymbol'; + +export function exportJSONSchema( + schema: Schema, + settings: { + includeId: boolean; + requireMandatoryProperties: boolean; + additionalProperties: boolean; + relaxed: boolean; + } +): object { + const typeToSchemaTypeMap: Record< + SimpleSchemaType, + { $ref: string } | { type: string } + > = { + Double: { $ref: '#/$defs/Double' }, + String: { type: 'string' }, + Binary: { $ref: '#/$defs/Binary' }, + Undefined: { $ref: '#/$defs/Undefined' }, + ObjectId: { $ref: '#/$defs/ObjectId' }, + Boolean: { type: 'boolean' }, + Date: { $ref: '#/$defs/Date' }, + Null: { type: 'null' }, + RegExp: { $ref: '#/$defs/RegExp' }, + DBRef: { $ref: '#/$defs/DBRef' }, + BSONSymbol: { $ref: '#/$defs/BSONSymbol' }, + Code: { $ref: '#/$defs/Code' }, + Int32: settings.relaxed ? RELAXED.Int32 : { $ref: '#/$defs/Int32' }, + Timestamp: { $ref: '#/$defs/Timestamp' }, + Long: settings.relaxed ? RELAXED.Long : { $ref: '#/$defs/Long' }, + Decimal128: { $ref: '#/$defs/Decimal128' }, + MinKey: { $ref: '#/$defs/MinKey' }, + MaxKey: { $ref: '#/$defs/MaxKey' }, + }; + + function processSchemaTypes(types: SchemaType[]): object { + // if the only type is Undefined, we use it + if (types.length === 1) { + return processSchemaType(types[0]); + } + + // if there are more types we remove 'Undefined', since the "probability" of the remaining types + // is not 1, the property will just not be 'required'. + const typesWithoutUndefined = types.filter((t) => t.name !== 'Undefined'); + + if (typesWithoutUndefined.length === 1) { + return processSchemaType(typesWithoutUndefined[0]); + } + + return { + anyOf: typesWithoutUndefined.map((t) => processSchemaType(t)), + }; + } + + function getRequiredFields(fields: SchemaField[]) { + const required = fields + .filter((f) => f.probability === 1) + .map((f) => f.name); + + return required.length ? required : undefined; + } + + function processDocumentType(fields: SchemaField[]) { + const properties: { [key: string]: object } = {}; + + for (const field of fields) { + properties[field.name] = processSchemaTypes(field.types); + } + + return { + properties, + ...(settings.requireMandatoryProperties + ? { required: getRequiredFields(fields) } + : {}), + + ...(settings.additionalProperties === false + ? { additionalProperties: false } + : {}), + }; + } + + const definitions: Partial> = {}; + + function processSchemaType(type: SchemaType): object { + if (type.name === 'Document') { + return { + type: 'object', + ...processDocumentType(type.fields), + }; + } + + if (type.name === 'Array') { + return { + type: 'array', + items: processSchemaTypes(type.types), + }; + } + + const simpleTypeName = type.name as SimpleSchemaType; + const schemaType = typeToSchemaTypeMap[simpleTypeName]; + + if (!schemaType) { + throw new Error(`Unrecognized type: "${type.name}"`); + } + + // Since we are using a type we need to add it to the definitions + const ejsonFormat = settings.relaxed ? RELAXED : CANONICAL; + const definition = (ejsonFormat as any)[simpleTypeName]; + + if (definition) { + definitions[simpleTypeName] = definition; + } + + return { ...schemaType }; + } + + const fields = settings.includeId + ? schema.fields + : schema.fields.filter((f) => f.name !== '_id'); + + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...processDocumentType(fields), + $defs: Object.keys(definitions).length ? definitions : undefined, + }; +} diff --git a/packages/compass-schema/src/components/export-schema/formats/mongodb-json-schema.spec.ts b/packages/compass-schema/src/components/export-schema/formats/mongodb-json-schema.spec.ts new file mode 100644 index 00000000000..9041411f384 --- /dev/null +++ b/packages/compass-schema/src/components/export-schema/formats/mongodb-json-schema.spec.ts @@ -0,0 +1,1398 @@ +import { expect } from 'chai'; +import { exportMongodbJSONSchema } from './mongodb-json-schema'; + +const mixed = { + fields: [ + { + name: '_id', + path: '_id', + count: 2, + types: [ + { + name: 'ObjectId', + bsonType: 'ObjectId', + path: '_id', + count: 2, + values: ['62fd21136bc4507c881228f8', '62fd21136bc4507c881228f7'], + total_count: 0, + probability: 1, + unique: 2, + has_duplicates: false, + }, + ], + total_count: 2, + type: 'ObjectId', + has_duplicates: false, + probability: 1, + }, + { + name: 'objectOrString', + path: 'objectOrString', + count: 2, + types: [ + { + name: 'String', + bsonType: 'String', + path: 'objectOrString', + count: 1, + values: ['this is a string'], + total_count: 0, + probability: 0.5, + unique: 1, + has_duplicates: false, + }, + { + name: 'Document', + bsonType: 'Document', + path: 'objectOrString', + count: 1, + fields: [ + { + name: 'thisIsAnObject', + path: 'objectOrString.thisIsAnObject', + count: 1, + types: [ + { + name: 'Boolean', + bsonType: 'Boolean', + path: 'objectOrString.thisIsAnObject', + count: 1, + values: [true], + total_count: 0, + probability: 1, + unique: 1, + has_duplicates: false, + }, + ], + total_count: 1, + type: 'Boolean', + has_duplicates: false, + probability: 1, + }, + ], + total_count: 0, + probability: 0.5, + }, + ], + total_count: 2, + type: ['String', 'Document'], + has_duplicates: false, + probability: 1, + }, + { + name: 'x', + path: 'x', + count: 2, + types: [ + { + name: 'Int32', + bsonType: 'Int32', + path: 'x', + count: 1, + values: [1], + total_count: 0, + probability: 0.5, + unique: 1, + has_duplicates: false, + }, + { + name: 'String', + bsonType: 'String', + path: 'x', + count: 1, + values: ['2'], + total_count: 0, + probability: 0.5, + unique: 1, + has_duplicates: false, + }, + ], + total_count: 2, + type: ['Int32', 'String'], + has_duplicates: false, + probability: 1, + }, + ], + count: 2, +}; + +const allBsonTypes = { + fields: [ + { + name: '_id', + path: '_id', + count: 1, + types: [ + { + name: 'ObjectId', + bsonType: 'ObjectId', + path: '_id', + count: 1, + values: ['5d505646cf6d4fe581014ab2'], + total_count: 0, + probability: 1, + unique: 1, + has_duplicates: false, + }, + ], + total_count: 1, + type: 'ObjectId', + has_duplicates: false, + probability: 1, + }, + { + name: 'arrayField_canonical', + path: 'arrayField_canonical', + count: 1, + types: [ + { + name: 'Array', + bsonType: 'Array', + path: 'arrayField_canonical', + count: 1, + types: [ + { + name: 'String', + bsonType: 'String', + path: 'arrayField_canonical', + count: 2, + values: [ + "import React, {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { css, cx } from '@leafygreen-ui/emotion';\nimport { uiColors } from '@leafygreen-ui/palette';\nimport type {\n default as HadronDocumentType,\n Element as HadronElementType,\n Editor as EditorType,\n} from 'hadron-document';\nimport { ElementEvents, ElementEditor } from 'hadron-document';\nimport BSONValue from '../bson-value';\nimport { fontFamilies, spacing } from '@leafygreen-ui/tokens';\nimport { KeyEditor, ValueEditor, TypeEditor } from './element-editors';\nimport { EditActions, AddFieldActions } from './element-actions';\nimport { FontAwesomeIcon } from './font-awesome-icon';\nimport { useAutoFocusContext, AutoFocusContext } from './auto-focus-context';\n\nfunction useForceUpdate() {\n const [, setState] = useState({});\n const forceUpdate = useCallback(() => {\n setState({});\n }, []);\n return forceUpdate;\n}\n\nfunction getEditorByType(type: HadronElementType['type']) {\n switch (type) {\n case 'Date':\n case 'String':\n case 'Decimal128':\n case 'Double':\n case 'Int32':\n case 'Int64':\n case 'Null':\n case 'Undefined':\n case 'ObjectId':\n return ElementEditor[`${type}Editor` as const];\n default:\n return ElementEditor.StandardEditor;\n }\n}\n\nfunction useElementEditor(el: HadronElementType) {\n const editor = useRef(null);\n\n if (!editor.current) {\n const Editor = getEditorByType(el.currentType);\n editor.current = new Editor(el);\n }\n\n useEffect(() => {\n if (\n editor.current?.element.uuid !== el.uuid ||\n editor.current?.element.currentType !== el.currentType\n ) {\n const Editor = getEditorByType(el.currentType);\n editor.current = new Editor(el);\n }\n }, [el, el.uuid, el.currentType]);\n\n return editor.current;\n}\n\nfunction useHadronElement(el: HadronElementType) {\n const forceUpdate = useForceUpdate();\n const editor = useElementEditor(el);\n // NB: Duplicate key state is kept local to the component and not derived on\n // every change so that only the changed key is highlighed as duplicate\n const [isDuplicateKey, setIsDuplicateKey] = useState(() => {\n return el.isDuplicateKey(el.currentKey);\n });\n\n const onElementChanged = useCallback(\n (changedElement: HadronElementType) => {\n if (el.uuid === changedElement.uuid) {\n forceUpdate();\n }\n },\n [el, forceUpdate]\n );\n\n const onElementAddedOrRemoved = useCallback(\n (\n _el: HadronElementType,\n parentEl: HadronElementType | HadronDocumentType | null\n ) => {\n if (el === parentEl) {\n forceUpdate();\n }\n },\n [el, forceUpdate]\n );\n\n useEffect(() => {\n el.on(ElementEvents.Converted, onElementChanged);\n el.on(ElementEvents.Edited, onElementChanged);\n el.on(ElementEvents.Reverted, onElementChanged);\n el.on(ElementEvents.Invalid, onElementChanged);\n el.on(ElementEvents.Valid, onElementChanged);\n el.on(ElementEvents.Added, onElementAddedOrRemoved);\n el.on(ElementEvents.Removed, onElementAddedOrRemoved);\n\n return () => {\n el.off(ElementEvents.Converted, onElementChanged);\n el.off(ElementEvents.Edited, onElementChanged);\n el.off(ElementEvents.Reverted, onElementChanged);\n el.off(ElementEvents.Valid, onElementChanged);\n el.off(ElementEvents.Added, onElementAddedOrRemoved);\n el.off(ElementEvents.Removed, onElementAddedOrRemoved);\n };\n }, [el, onElementChanged, onElementAddedOrRemoved]);\n\n const isValid = el.isCurrentTypeValid();\n\n return {\n id: el.uuid,\n key: {\n value: el.currentKey,\n change(newVal: string) {\n setIsDuplicateKey(el.isDuplicateKey(newVal));\n el.rename(newVal);\n },\n // TODO: isKeyEditable should probably account for Array parents on it's\n // own, but right now `isValueEditable` has a weird dependency on it and\n // so marking aray keys uneditable breaks value editing\n editable: el.isKeyEditable() && el.parent?.currentType !== 'Array',\n valid: !isDuplicateKey,\n validationMessage: isDuplicateKey\n ? `Duplicate key \"${el.currentKey}\" - this will overwrite previous values`\n : null,\n },\n value: {\n value: editor.value(),\n originalValue: el.currentValue,\n change(newVal: string) {\n editor.edit(newVal);\n },\n editable:\n el.isValueEditable() &&\n el.currentType !== 'Object' &&\n el.currentType !== 'Array',\n valid: isValid,\n validationMessage: !isValid ? el.invalidTypeMessage ?? null : null,\n },\n type: {\n value: el.currentType,\n change(newVal: HadronElementType['type']) {\n el.changeType(newVal);\n },\n },\n revert: el.isRevertable() ? el.revert.bind(el) : null,\n remove: el.isNotActionable() ? null : el.remove.bind(el),\n expandable: Boolean(el.elements),\n children: el.elements ? [...el.elements] : [],\n level: el.level,\n parentType: el.parent?.currentType,\n removed: el.isRemoved(),\n };\n}\n\nconst buttonReset = css({\n margin: 0,\n padding: 0,\n border: 'none',\n background: 'none',\n});\n\nconst hadronElement = css({\n display: 'flex',\n paddingLeft: spacing[2],\n paddingRight: spacing[2],\n '&:hover': {\n backgroundColor: uiColors.gray.light2,\n },\n});\n\nconst elementInvalid = css({\n backgroundColor: uiColors.yellow.light3,\n '&:hover': {\n backgroundColor: uiColors.yellow.light2,\n },\n});\n\nconst elementRemoved = css({\n backgroundColor: uiColors.red.light3,\n '&:hover': {\n backgroundColor: uiColors.red.light2,\n },\n});\n\nconst elementActions = css({\n flex: 'none',\n width: spacing[3],\n});\n\nconst elementLineNumber = css({\n flex: 'none',\n position: 'relative',\n marginLeft: spacing[1],\n boxSizing: 'content-box',\n});\n\nconst addFieldActionsContainer = css({\n position: 'absolute',\n top: 0,\n right: 0,\n});\n\nconst lineNumberCount = css({\n '&::before': {\n display: 'block',\n width: '100%',\n counterIncrement: 'line-number',\n content: 'counter(line-number)',\n textAlign: 'end',\n color: uiColors.gray.base,\n },\n});\n\nconst lineNumberInvalid = css({\n backgroundColor: uiColors.yellow.base,\n '&::before': {\n color: uiColors.yellow.dark2,\n },\n});\n\nconst lineNumberRemoved = css({\n backgroundColor: uiColors.red.base,\n color: uiColors.red.light3,\n '&::before': {\n color: uiColors.red.light3,\n },\n});\n\nconst elementSpacer = css({\n flex: 'none',\n});\n\nconst elementExpand = css({\n width: spacing[3],\n flex: 'none',\n});\n\nconst elementKey = css({\n flex: 'none',\n fontWeight: 'bold',\n maxWidth: '70%',\n // textOverflow: 'ellipsis',\n});\n\nconst elementDivider = css({\n flex: 'none',\n userSelect: 'none',\n});\n\nconst elementValue = css({\n flex: 1,\n minWidth: 0,\n maxWidth: '100%',\n});\n\nconst elementType = css({\n flex: 'none',\n});\n\nconst actions = css({\n display: 'none',\n});\n\nconst actionsVisible = css({\n // We are deliberately not using useFocus and useHover hooks in the component\n // as these listeners are expensive and so with the amount of code and DOM on\n // the screen causes noticeable frame skips in the browser. This code is a bit\n // brittle as we can't really reference the exact className of an emotion\n // style so we have to rely on data attributes, but the chances that this\n // should ever be an issue are pretty slim\n '[data-document-element=\"true\"]:hover &, [data-document-element=\"true\"]:focus-within &':\n {\n display: 'block',\n },\n});\n\nconst lineNumberCountHidden = css({\n // See above\n '[data-document-element=\"true\"]:hover &::before, [data-document-element=\"true\"]:focus-within &::before':\n {\n visibility: 'hidden',\n },\n});\n\nconst HadronElement: React.FunctionComponent<{\n value: HadronElementType;\n editingEnabled: boolean;\n onEditStart?: (id: string, field: 'key' | 'value') => void;\n allExpanded: boolean;\n lineNumberSize: number;\n onAddElement(el: HadronElementType): void;\n}> = ({\n value: element,\n editingEnabled,\n onEditStart,\n allExpanded,\n lineNumberSize,\n onAddElement,\n}) => {\n const autoFocus = useAutoFocusContext();\n const [expanded, setExpanded] = useState(allExpanded);\n const {\n id,\n key,\n value,\n type,\n revert,\n remove,\n expandable,\n children,\n level,\n parentType,\n removed,\n } = useHadronElement(element);\n\n useEffect(() => {\n setExpanded(allExpanded);\n }, [allExpanded]);\n\n const toggleExpanded = useCallback(() => {\n setExpanded((val) => !val);\n }, []);\n\n const lineNumberMinWidth = useMemo(() => {\n // Only account for ~ line count length if we are in editing mode\n if (editingEnabled) {\n const charCount = String(lineNumberSize).length;\n return charCount > 2 ? `${charCount}.5ch` : spacing[3];\n }\n return spacing[3];\n }, [lineNumberSize, editingEnabled]);\n\n const onLineClick = useCallback(() => {\n toggleExpanded();\n }, [toggleExpanded]);\n\n const isValid = key.valid && value.valid;\n const shouldShowActions = editingEnabled;\n\n const elementProps = {\n className: cx(\n hadronElement,\n removed ? elementRemoved : editingEnabled && !isValid && elementInvalid\n ),\n onClick() {\n onLineClick();\n },\n };\n\n return (\n <>\n
\n
\n
\n \n
\n
\n \n = { + Double: 'double', + Number: 'double', + String: 'string', + Document: 'object', + Array: 'array', + Binary: 'binData', + Undefined: 'undefined', + ObjectId: 'objectId', + Boolean: 'bool', + Date: 'date', + Null: 'null', + RegExp: 'regex', + DBRef: 'dbPointer', + BSONSymbol: 'symbol', + Symbol: 'symbol', + Code: 'javascript', + Int32: 'int', + Timestamp: 'timestamp', + Long: 'long', + Decimal128: 'decimal', + MinKey: 'minKey', + MaxKey: 'maxKey', +}; + +function processSchemaTypes(types: SchemaType[], settings: Settings): object { + if (types.length === 1) { + return processSchemaType(types[0], settings); + } + + const hasComplexTypes = types.some((type: SchemaType) => + ['Document', 'Array'].includes(type.name) + ); + + if (hasComplexTypes) { + return { + anyOf: types.map((t) => processSchemaType(t, settings)), + }; + } + + return { + bsonType: types.map((t) => typeToSchemaBsonTypeMap[t.name]), + }; +} + +function getRequiredFields(fields: SchemaField[]) { + const required = fields.filter((f) => f.probability === 1).map((f) => f.name); + + return required.length ? required : undefined; +} + +function processDocumentType(fields: SchemaField[], settings: Settings) { + const properties: { [key: string]: object } = {}; + + for (const field of fields) { + properties[field.name] = processSchemaTypes(field.types, settings); + } + + return { + properties, + ...(settings.requireMandatoryProperties + ? { required: getRequiredFields(fields) } + : {}), + + ...(settings.additionalProperties === false + ? { additionalProperties: false } + : {}), + }; +} + +function processSchemaType(type: SchemaType, settings: Settings): object { + const schemaBsonType = typeToSchemaBsonTypeMap[type.name]; + + if (!schemaBsonType) { + throw new Error(`Unrecognized type: "${type.name}"`); + } + + if (type.name === 'Document') { + return { + bsonType: schemaBsonType, + ...processDocumentType(type.fields, settings), + }; + } + + if (type.name === 'Array') { + return { + bsonType: schemaBsonType, + items: processSchemaTypes(type.types, settings), + }; + } + + return { bsonType: schemaBsonType }; +} + +export function exportMongodbJSONSchema( + schema: Schema, + settings: Settings +): object { + const fields = settings.includeId + ? schema.fields + : schema.fields.filter((f) => f.name !== '_id'); + + return { + $jsonSchema: { + ...processDocumentType(fields, settings), + }, + }; +} diff --git a/packages/compass-schema/src/components/schema-toolbar/schema-toolbar.tsx b/packages/compass-schema/src/components/schema-toolbar/schema-toolbar.tsx index 38541c28b41..548b78c96d0 100644 --- a/packages/compass-schema/src/components/schema-toolbar/schema-toolbar.tsx +++ b/packages/compass-schema/src/components/schema-toolbar/schema-toolbar.tsx @@ -6,6 +6,8 @@ import { WarningSummary, css, spacing, + Button, + Icon, } from '@mongodb-js/compass-components'; import type { AnalysisState } from '../../constants/analysis-states'; @@ -63,6 +65,7 @@ type SchemaToolbarProps = { localAppRegistry: AppRegistry; onAnalyzeSchemaClicked: () => void; onResetClicked: () => void; + onExportSchemaClicked: () => void; sampleSize: number; schemaResultId: string; }; @@ -73,6 +76,7 @@ const SchemaToolbar: React.FunctionComponent = ({ isOutdated, localAppRegistry, onAnalyzeSchemaClicked, + onExportSchemaClicked, onResetClicked, sampleSize, schemaResultId, @@ -110,6 +114,16 @@ const SchemaToolbar: React.FunctionComponent = ({
{analysisState === ANALYSIS_STATE_COMPLETE && !isOutdated && (
+
+ +
{ this.geoLayers = {}; }, - getShareText() { - if (this.state.schema !== null) { - return `The schema definition of ${this.ns} has been copied to your clipboard in JSON format.`; - } - return 'Please Analyze the Schema First from the Schema Tab.'; + openExportSchema() { + this.setState({ + exportSchemaOpened: true, + }); }, - handleSchemaShare() { - navigator.clipboard.writeText( - JSON.stringify(this.state.schema, null, ' ') - ); - ipc.call('app:show-info-dialog', 'Share Schema', this.getShareText()); + closeExportSchema() { + this.setState({ exportSchemaOpened: false }); }, /** @@ -158,6 +154,7 @@ const configureStore = (options = {}) => { isActiveTab: false, resultId: resultId(), abortController: undefined, + exportSchemaOpened: false, }; }, @@ -377,14 +374,6 @@ const configureStore = (options = {}) => { store.onQueryChanged(state); }); - /** - * When `Share Schema as JSON` clicked in menu show a dialog message. - */ - options.localAppRegistry.on( - 'menu-share-schema-json', - store.handleSchemaShare - ); - setLocalAppRegistry(store, options.localAppRegistry); } diff --git a/packages/compass/src/main/menu.ts b/packages/compass/src/main/menu.ts index 9275c279e77..794d3b2613c 100644 --- a/packages/compass/src/main/menu.ts +++ b/packages/compass/src/main/menu.ts @@ -266,14 +266,6 @@ function helpSubMenu( function collectionSubMenu(menuReadOnly: boolean): MenuItemConstructorOptions { const subMenu = []; - subMenu.push({ - label: '&Share Schema as JSON', - accelerator: 'Alt+CmdOrCtrl+S', - click() { - ipcMain.broadcastFocused('window:menu-share-schema-json'); - }, - }); - subMenu.push(separator()); if (!preferences.getPreferences().readOnly && !menuReadOnly) { subMenu.push({ label: '&Import Data',