diff --git a/package-lock.json b/package-lock.json index 41d711acf4b..5cbc368642d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45275,10 +45275,13 @@ "@mongodb-js/compass-collection": "^4.42.0", "@mongodb-js/compass-components": "^1.30.0", "@mongodb-js/compass-connections": "^1.43.0", + "@mongodb-js/compass-field-store": "^9.18.0", "@mongodb-js/compass-logging": "^1.4.8", "@mongodb-js/compass-telemetry": "^1.2.0", "hadron-app-registry": "^9.2.7", + "mongodb-ns": "^2.4.2", "react": "^17.0.2", + "react-redux": "^8.1.3", "redux": "^4.2.1", "redux-thunk": "^2.4.2" }, @@ -57005,6 +57008,7 @@ "@mongodb-js/compass-collection": "^4.42.0", "@mongodb-js/compass-components": "^1.30.0", "@mongodb-js/compass-connections": "^1.43.0", + "@mongodb-js/compass-field-store": "^9.18.0", "@mongodb-js/compass-logging": "^1.4.8", "@mongodb-js/compass-telemetry": "^1.2.0", "@mongodb-js/eslint-config-compass": "^1.1.7", @@ -57023,9 +57027,11 @@ "eslint": "^7.25.0", "hadron-app-registry": "^9.2.7", "mocha": "^10.2.0", + "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "prettier": "^2.7.1", "react": "^17.0.2", + "react-redux": "^8.1.3", "redux": "^4.2.1", "redux-thunk": "^2.4.2", "sinon": "^17.0.1", diff --git a/packages/compass-global-writes/package.json b/packages/compass-global-writes/package.json index 45b351a258e..af67762fb58 100644 --- a/packages/compass-global-writes/package.json +++ b/packages/compass-global-writes/package.json @@ -56,7 +56,10 @@ "@mongodb-js/compass-logging": "^1.4.8", "@mongodb-js/compass-telemetry": "^1.2.0", "hadron-app-registry": "^9.2.7", + "@mongodb-js/compass-field-store": "^9.18.0", + "mongodb-ns": "^2.4.2", "react": "^17.0.2", + "react-redux": "^8.1.3", "redux": "^4.2.1", "redux-thunk": "^2.4.2" }, diff --git a/packages/compass-global-writes/src/components/index.spec.tsx b/packages/compass-global-writes/src/components/index.spec.tsx index eeb039f28f5..e264d94197d 100644 --- a/packages/compass-global-writes/src/components/index.spec.tsx +++ b/packages/compass-global-writes/src/components/index.spec.tsx @@ -1,12 +1,29 @@ import React from 'react'; import { expect } from 'chai'; -import { render, screen } from '@mongodb-js/testing-library-compass'; +import { screen } from '@mongodb-js/testing-library-compass'; import { GlobalWrites } from './index'; +import { renderWithStore } from './../../tests/create-store'; describe('Compass GlobalWrites Plugin', function () { - it('renders a Plugin', function () { - render(); - expect(screen.getByText('This feature is currently in development.')).to - .exist; + it('renders plugin in NOT_READY state', function () { + renderWithStore(); + expect(screen.getByText(/loading/i)).to.exist; + }); + + it('renders plugin in UNSHARDED state', function () { + renderWithStore(); + expect(screen.getByTestId('shard-collection-button')).to.exist; + }); + + it('renders plugin in SUBMITTING_FOR_SHARDING state', function () { + renderWithStore( + + ); + expect(screen.getByTestId('shard-collection-button')).to.exist; + }); + + it('renders plugin in SHARDING state', function () { + renderWithStore(); + expect(screen.getByText(/sharding your collection/i)).to.exist; }); }); diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx index cf5d50f8f1a..ff0a5b02fa4 100644 --- a/packages/compass-global-writes/src/components/index.tsx +++ b/packages/compass-global-writes/src/components/index.tsx @@ -1,19 +1,28 @@ +import React from 'react'; +import { connect } from 'react-redux'; import { css, spacing, WorkspaceContainer, - Body, + SpinLoaderWithLabel, } from '@mongodb-js/compass-components'; -import React from 'react'; +import type { RootState, ShardingStatus } from '../store/reducer'; +import { ShardingStatuses } from '../store/reducer'; +import UnshardedState from './states/unsharded'; +import ShardingState from './states/sharding'; const containerStyles = css({ - paddingLeft: spacing[3], - paddingRight: spacing[3], + paddingLeft: spacing[400], + paddingRight: spacing[400], display: 'flex', width: '100%', height: '100%', }); +const workspaceContentStyles = css({ + paddingTop: spacing[400], +}); + const centeredContent = css({ display: 'flex', justifyContent: 'center', @@ -21,14 +30,46 @@ const centeredContent = css({ height: '100%', }); -export function GlobalWrites() { +type GlobalWritesProps = { + shardingStatus: ShardingStatus; +}; + +function ShardingStateView({ + shardingStatus, +}: { + shardingStatus: ShardingStatus; +}) { + if (shardingStatus === ShardingStatuses.NOT_READY) { + return ( +
+ +
+ ); + } + + if ( + shardingStatus === ShardingStatuses.UNSHARDED || + shardingStatus === ShardingStatuses.SUBMITTING_FOR_SHARDING + ) { + return ; + } + + if (shardingStatus === ShardingStatuses.SHARDING) { + return ; + } + + return null; +} + +export function GlobalWrites({ shardingStatus }: GlobalWritesProps) { return (
- - - This feature is currently in development. - + +
); } +export default connect((state: RootState) => ({ + shardingStatus: state.status, +}))(GlobalWrites); diff --git a/packages/compass-global-writes/src/components/states/sharding.spec.tsx b/packages/compass-global-writes/src/components/states/sharding.spec.tsx new file mode 100644 index 00000000000..beb6e372928 --- /dev/null +++ b/packages/compass-global-writes/src/components/states/sharding.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen } from '@mongodb-js/testing-library-compass'; +import { ShardingState } from './sharding'; +import { renderWithStore } from '../../../tests/create-store'; + +function renderWithProps( + props?: Partial> +) { + return renderWithStore(); +} + +describe('Sharding', function () { + it('renders the info banner', function () { + renderWithProps(); + expect(screen.getByRole('alert')).to.exist; + }); +}); diff --git a/packages/compass-global-writes/src/components/states/sharding.tsx b/packages/compass-global-writes/src/components/states/sharding.tsx new file mode 100644 index 00000000000..f64e788a4ab --- /dev/null +++ b/packages/compass-global-writes/src/components/states/sharding.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { + Banner, + BannerVariant, + Body, + css, + Link, + spacing, +} from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; + +const nbsp = '\u00a0'; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], +}); + +export function ShardingState() { + return ( +
+ + Sharding your collection … + {nbsp}this should not take too long. + + + Once your collection is sharded, this tab will show instructions on + document ‘location’ field formatting, and provide some common command + examples. + + + You can read more about Global Writes in our documentation. + +
+ ); +} + +export default connect()(ShardingState); diff --git a/packages/compass-global-writes/src/components/states/unsharded.tsx b/packages/compass-global-writes/src/components/states/unsharded.tsx new file mode 100644 index 00000000000..073929dfe0d --- /dev/null +++ b/packages/compass-global-writes/src/components/states/unsharded.tsx @@ -0,0 +1,349 @@ +import React, { useCallback, useState } from 'react'; +import { + Banner, + BannerVariant, + Body, + css, + Label, + Link, + spacing, + Subtitle, + InlineInfoLink, + TextInput, + Accordion, + RadioGroup, + Radio, + ComboboxWithCustomOption, + ComboboxOption, + Checkbox, + Button, + SpinLoader, + cx, +} from '@mongodb-js/compass-components'; +import { useAutocompleteFields } from '@mongodb-js/compass-field-store'; +import { connect } from 'react-redux'; +import type { CreateShardKeyData, RootState } from '../../store/reducer'; +import { createShardKey, ShardingStatuses } from '../../store/reducer'; + +const nbsp = '\u00a0'; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], +}); + +const contentStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[200], +}); + +const listStyles = css({ + listStyle: 'disc', + paddingLeft: 'auto', + marginTop: 0, +}); + +const shardKeyFormFieldsStyles = css({ + display: 'flex', + flexDirection: 'row', + gap: spacing[400], +}); + +const secondShardKeyStyles = css({ + width: '300px', +}); + +const hasedIndexOptionsStyles = css({ + marginLeft: spacing[1200], // This aligns it with the radio button text + marginTop: spacing[400], +}); + +const advanceOptionsGroupStyles = css({ + paddingLeft: spacing[500], // Avoid visual cutoff +}); + +const chunksInputStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[100], +}); + +function CreateShardKeyDescription() { + return ( +
+ Configure compound shard key + + To properly configure Global Writes, your collections must be sharded + using a compound shard key made up of a ‘location’ field and a second + field of your choosing. + + + + All documents in your collection should contain both the ‘location’ + field and your chosen second field. + + +
    +
  • + + The second field should represent a well-distributed and immutable + value to ensure that data is equally distributed across shards in a + particular zone.{nbsp} + + Note that the value of this field cannot be an array. + + {nbsp} + For more information, read our documentation on{' '} + + selecting a shard key + + . + +
  • +
+ + + Once you shard your collection, it cannot be unsharded. + +
+ ); +} + +type ShardingAdvancedOption = 'default' | 'unique-index' | 'hashed-index'; + +function CreateShardKeyForm({ + namespace, + isSubmittingForSharding, + onCreateShardKey, +}: Pick< + UnshardedStateProps, + 'isSubmittingForSharding' | 'namespace' | 'onCreateShardKey' +>) { + const [isAdvancedOptionsOpen, setIsAdvancedOptionsOpen] = useState(false); + const [selectedAdvancedOption, setSelectedAdvancedOption] = + useState('default'); + const fields = useAutocompleteFields(namespace); + + const [secondShardKey, setSecondShardKey] = useState(null); + const [numInitialChunks, setNumInitialChunks] = useState< + string | undefined + >(); + const [isPreSplitData, setIsPreSplitData] = useState(false); + + const onSubmit = useCallback(() => { + if (!secondShardKey) { + return; + } + const isCustomShardKeyHashed = selectedAdvancedOption === 'hashed-index'; + const presplitHashedZones = isCustomShardKeyHashed && isPreSplitData; + + const data: CreateShardKeyData = { + customShardKey: secondShardKey, + isShardKeyUnique: selectedAdvancedOption === 'unique-index', + isCustomShardKeyHashed, + presplitHashedZones, + numInitialChunks: + presplitHashedZones && numInitialChunks + ? Number(numInitialChunks) + : null, + }; + + onCreateShardKey(data); + }, [ + isPreSplitData, + numInitialChunks, + secondShardKey, + selectedAdvancedOption, + onCreateShardKey, + ]); + + return ( +
+
+
+ + +
+
+ + ({ value }))} + className={secondShardKeyStyles} + value={secondShardKey} + searchEmptyMessage="No fields found. Please enter a valid field name." + renderOption={(option, index, isCustom) => { + return ( + + ); + }} + /> +
+
+ + ) => { + setSelectedAdvancedOption( + event.target.value as ShardingAdvancedOption + ); + }} + > + + Default + + +
+ + + Enforce a uniqueness constraint on the shard key of this Global + Collection.{' '} + + Learn more + + +
+
+ +
+ + + Improve even distribution of the sharded data by hashing the + second field of the shard key.{' '} + + Learn more + + +
+
+
+ {selectedAdvancedOption === 'hashed-index' && ( +
+ setIsPreSplitData(!isPreSplitData)} + label="Pre-split data for even distribution." + checked={isPreSplitData} + /> +
+ setNumInitialChunks(event.target.value)} + /> + chunks per shard. +
+
+ )} +
+
+ +
+
+ ); +} + +type UnshardedStateProps = { + namespace: string; + isSubmittingForSharding: boolean; + onCreateShardKey: (data: CreateShardKeyData) => void; +}; +export function UnshardedState(props: UnshardedStateProps) { + return ( +
+ + + To use Global Writes, this collection must be configured with a + compound shard key made up of both a ‘location’ field and an + identifier field that you should provide. + + {nbsp}See the instructions below for details. + + + +
+ ); +} + +export default connect( + (state: RootState) => ({ + namespace: state.namespace, + isSubmittingForSharding: + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING, + }), + { + onCreateShardKey: createShardKey, + } +)(UnshardedState); diff --git a/packages/compass-global-writes/src/components/states/usharded.spec.tsx b/packages/compass-global-writes/src/components/states/usharded.spec.tsx new file mode 100644 index 00000000000..a26d5b228de --- /dev/null +++ b/packages/compass-global-writes/src/components/states/usharded.spec.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { UnshardedState } from './unsharded'; +import { renderWithStore } from '../../../tests/create-store'; +import sinon from 'sinon'; + +function renderWithProps( + props?: Partial> +) { + return renderWithStore( + {}} + {...props} + /> + ); +} + +function setShardingKeyFieldValue(value: string) { + const input = screen.getByLabelText('Second shard key field'); + expect(input).to.exist; + userEvent.type(input, value); + expect(input).to.have.value(value); + userEvent.keyboard('{Escape}'); + + // For some reason, when running tests in electron mode, the value of + // the input field is not being updated. This is a workaround to ensure + // the value is being updated before clicking the submit button. + userEvent.click(screen.getByText(value), undefined, { + skipPointerEventsCheck: true, + }); +} + +describe('UnshardedState', function () { + it('renders the warning banner', function () { + renderWithProps(); + expect(screen.getByRole('alert')).to.exist; + }); + + it('renders the text to the user', function () { + renderWithProps(); + expect(screen.getByTestId('unsharded-text-description')).to.exist; + }); + + context('shard collection form', function () { + let onCreateShardKeySpy: sinon.SinonSpy; + beforeEach(function () { + onCreateShardKeySpy = sinon.spy(); + renderWithProps({ onCreateShardKey: onCreateShardKeySpy }); + }); + + it('renders location form field as disabled', function () { + expect(screen.getByLabelText('First shard key field')).to.have.attribute( + 'aria-disabled', + 'true' + ); + }); + + it('does not allow user to submit when no second shard key is selected', function () { + expect(screen.getByTestId('shard-collection-button')).to.have.attribute( + 'aria-disabled', + 'true' + ); + + userEvent.click(screen.getByTestId('shard-collection-button')); + expect(onCreateShardKeySpy.called).to.be.false; + }); + + it('allows user to input second shard key and submit it', function () { + setShardingKeyFieldValue('name'); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.true; + expect(onCreateShardKeySpy.firstCall.args[0]).to.deep.equal({ + customShardKey: 'name', + isShardKeyUnique: false, + isCustomShardKeyHashed: false, + presplitHashedZones: false, + numInitialChunks: null, + }); + }); + + it('renders advanced options and radio buttons for: default, unique-index and hashed index', function () { + const accordian = screen.getByText('Advanced Shard Key Configuration'); + expect(accordian).to.exist; + + userEvent.click(accordian); + + const defaultRadio = screen.getByLabelText('Default'); + const uniqueIndexRadio = screen.getByLabelText( + 'Use unique index as the shard key' + ); + const hashedIndexRadio = screen.getByLabelText( + 'Use hashed index as the shard key' + ); + + expect(defaultRadio).to.exist; + expect(uniqueIndexRadio).to.exist; + expect(hashedIndexRadio).to.exist; + }); + + it('allows user to select unique index as shard key', function () { + const accordian = screen.getByText('Advanced Shard Key Configuration'); + userEvent.click(accordian); + + const uniqueIndexRadio = screen.getByLabelText( + 'Use unique index as the shard key' + ); + userEvent.click(uniqueIndexRadio); + + expect(uniqueIndexRadio).to.have.attribute('aria-checked', 'true'); + + setShardingKeyFieldValue('name'); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.true; + expect(onCreateShardKeySpy.firstCall.args[0]).to.deep.equal({ + customShardKey: 'name', + isShardKeyUnique: true, + isCustomShardKeyHashed: false, + presplitHashedZones: false, + numInitialChunks: null, + }); + }); + + it('allows user to select hashed index as shard key with split-chunks option', function () { + const accordian = screen.getByText('Advanced Shard Key Configuration'); + userEvent.click(accordian); + + const hashedIndexRadio = screen.getByLabelText( + 'Use hashed index as the shard key' + ); + userEvent.click(hashedIndexRadio); + + expect(hashedIndexRadio).to.have.attribute('aria-checked', 'true'); + + setShardingKeyFieldValue('name'); + + // Check pre-split data + userEvent.click(screen.getByTestId('presplit-data-checkbox'), undefined, { + skipPointerEventsCheck: true, + }); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.true; + expect(onCreateShardKeySpy.firstCall.args[0]).to.deep.equal({ + customShardKey: 'name', + isShardKeyUnique: false, + isCustomShardKeyHashed: true, + presplitHashedZones: true, + numInitialChunks: null, + }); + }); + + it('allows user to select hashed index as shard key with all its options', function () { + const accordian = screen.getByText('Advanced Shard Key Configuration'); + userEvent.click(accordian); + + const hashedIndexRadio = screen.getByLabelText( + 'Use hashed index as the shard key' + ); + userEvent.click(hashedIndexRadio); + + expect(hashedIndexRadio).to.have.attribute('aria-checked', 'true'); + + setShardingKeyFieldValue('name'); + + // Check pre-split data + userEvent.click(screen.getByTestId('presplit-data-checkbox'), undefined, { + skipPointerEventsCheck: true, + }); + + // Enter number of chunks + userEvent.type(screen.getByTestId('chunks-per-shard-input'), '10'); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.true; + expect(onCreateShardKeySpy.firstCall.args[0]).to.deep.equal({ + customShardKey: 'name', + isShardKeyUnique: false, + isCustomShardKeyHashed: true, + presplitHashedZones: true, + numInitialChunks: 10, + }); + }); + }); +}); diff --git a/packages/compass-global-writes/src/index.ts b/packages/compass-global-writes/src/index.ts index 754e14a2086..a0cadcc0b76 100644 --- a/packages/compass-global-writes/src/index.ts +++ b/packages/compass-global-writes/src/index.ts @@ -1,7 +1,7 @@ import React from 'react'; import { registerHadronPlugin } from 'hadron-app-registry'; -import { GlobalWrites } from './components'; +import GlobalWrites from './components'; import { GlobalWritesTabTitle } from './plugin-title'; import { activateGlobalWritesPlugin } from './store'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; @@ -28,6 +28,6 @@ const CompassGlobalWritesHadronPlugin = registerHadronPlugin( export const CompassGlobalWritesPlugin = { name: 'GlobalWrites' as const, provider: CompassGlobalWritesHadronPlugin, - content: GlobalWrites, - header: GlobalWritesTabTitle, + content: GlobalWrites as React.FunctionComponent, + header: GlobalWritesTabTitle as React.FunctionComponent, }; diff --git a/packages/compass-global-writes/src/plugin-title.tsx b/packages/compass-global-writes/src/plugin-title.tsx index a733e03dee5..e7fd917d6d2 100644 --- a/packages/compass-global-writes/src/plugin-title.tsx +++ b/packages/compass-global-writes/src/plugin-title.tsx @@ -1,5 +1,78 @@ +import { connect } from 'react-redux'; import React from 'react'; +import { type RootState, ShardingStatuses } from './store/reducer'; +import { + Body, + css, + cx, + Icon, + palette, + spacing, + Tooltip, + useDarkMode, +} from '@mongodb-js/compass-components'; -export function GlobalWritesTabTitle() { - return
Global Writes
; -} +const containerStyles = css({ + display: 'flex', + gap: spacing[200], + alignItems: 'center', +}); + +const warningIconStyles = css({ + display: 'flex', +}); + +const iconStylesLight = css({ + color: palette.yellow.dark2, +}); + +const iconStylesDark = css({ + color: palette.yellow.base, +}); + +const PluginTitle = ({ showWarning }: { showWarning: boolean }) => { + const darkMode = useDarkMode(); + return ( +
+ Global Writes{' '} + {showWarning && ( + { + // LG does not bubble up the click event to the parent component, + // so we add noop onClick and let it bubble up. + }} + > + + + } + > + + Collections in Atlas Global Clusters with Atlas-managed sharding + must be configured with a compound shard key made up of both a + 'location' field and an identifier field that you provide. + Please configure sharding here. + + + )} +
+ ); +}; + +export const GlobalWritesTabTitle = connect( + ({ isNamespaceSharded, status }: RootState) => ({ + showWarning: !isNamespaceSharded && status !== ShardingStatuses.NOT_READY, + }) +)(PluginTitle); diff --git a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts index 44c7fc583de..d548eae05c3 100644 --- a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts +++ b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts @@ -1,5 +1,109 @@ +import toNS from 'mongodb-ns'; import type { AtlasService } from '@mongodb-js/atlas-service/provider'; +import type { CreateShardKeyData } from '../store/reducer'; + +type ZoneMapping = unknown; +export type ManagedNamespace = { + db: string; + collection: string; + customShardKey: string; + isCustomShardKeyHashed: boolean; + isShardKeyUnique: boolean; + numInitialChunks: number | null; + presplitHashedZones: boolean; +}; + +type GeoShardingData = { + customZoneMapping: Record; + managedNamespaces: ManagedNamespace[]; + selfManagedSharding: boolean; +}; + +type ClusterDetailsApiResponse = { + geoSharding: GeoShardingData; +}; + +type AtlasCluterInfo = { + projectId: string; + clusterName: string; +}; + +function assertDataIsClusterDetailsApiResponse( + data: any +): asserts data is ClusterDetailsApiResponse { + if (!Array.isArray(data?.geoSharding?.managedNamespaces)) { + throw new Error( + 'Invalid cluster details API response geoSharding.managedNamespaces' + ); + } + if (typeof data?.geoSharding?.customZoneMapping !== 'object') { + throw new Error( + 'Invalid cluster details API response geoSharding.customZoneMapping' + ); + } +} export class AtlasGlobalWritesService { constructor(private atlasService: AtlasService) {} + + private async fetchClusterDetails({ + clusterName, + projectId, + }: AtlasCluterInfo): Promise { + const uri = this.atlasService.cloudEndpoint( + `nds/clusters/${projectId}/${clusterName}` + ); + const response = await this.atlasService.authenticatedFetch(uri); + const clusterDetails = await response.json(); + assertDataIsClusterDetailsApiResponse(clusterDetails); + return clusterDetails; + } + + async isNamespaceManaged( + namespace: string, + atlasClusterInfo: AtlasCluterInfo + ) { + const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo); + const { database, collection } = toNS(namespace); + return clusterDetails.geoSharding.managedNamespaces.some( + (managedNamespace) => { + return ( + managedNamespace.db === database && + managedNamespace.collection === collection + ); + } + ); + } + + async createShardKey( + namespace: string, + keyData: CreateShardKeyData, + atlasClusterInfo: AtlasCluterInfo + ) { + const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo); + const { database, collection } = toNS(namespace); + const requestData: GeoShardingData = { + ...clusterDetails.geoSharding, + managedNamespaces: [ + ...clusterDetails.geoSharding.managedNamespaces, + { + db: database, + collection: collection, + ...keyData, + }, + ], + }; + + const uri = this.atlasService.cloudEndpoint( + `nds/clusters/${atlasClusterInfo.projectId}/${atlasClusterInfo.clusterName}/geoSharding` + ); + + await this.atlasService.authenticatedFetch(uri, { + method: 'PATCH', + body: JSON.stringify(requestData), + headers: { + 'Content-Type': 'application/json', + }, + }); + } } diff --git a/packages/compass-global-writes/src/store/index.spec.ts b/packages/compass-global-writes/src/store/index.spec.ts new file mode 100644 index 00000000000..643869dc71b --- /dev/null +++ b/packages/compass-global-writes/src/store/index.spec.ts @@ -0,0 +1,144 @@ +import { expect } from 'chai'; +import { type GlobalWritesStore } from '.'; +import { setupStore } from '../../tests/create-store'; +import { + fetchClusterShardingData, + createShardKey, + type CreateShardKeyData, +} from './reducer'; +import sinon from 'sinon'; + +const DB = 'test'; +const COLL = 'coll'; +const NS = `${DB}.${COLL}`; + +function createJsonResponse(data: any) { + return { + json: () => Promise.resolve(data), + }; +} + +function createStore(atlasService: any = {}): GlobalWritesStore { + return setupStore( + { + namespace: NS, + }, + { + atlasService, + } + ); +} + +describe('GlobalWritesStore Store', function () { + it('sets the initial state', function () { + const store = createStore(); + expect(store.getState().namespace).to.equal(NS); + expect(store.getState().status).to.equal('NOT_READY'); + }); + + context('actions', function () { + context('fetchClusterShardingData', function () { + it('when the namespace is not managed', async function () { + const store = createStore({ + authenticatedFetch: () => + createJsonResponse({ + geoSharding: { customZoneMapping: {}, managedNamespaces: [] }, + }), + }); + await store.dispatch(fetchClusterShardingData()); + expect(store.getState().status).to.equal('UNSHARDED'); + expect(store.getState().isNamespaceSharded).to.equal(false); + }); + + // TODO (COMPASS-8277): Add more test for fetching shard key and process errors + }); + + context('createShardKey', function () { + const shardKeyData: CreateShardKeyData = { + customShardKey: 'test', + isCustomShardKeyHashed: true, + isShardKeyUnique: false, + numInitialChunks: 1, + presplitHashedZones: true, + }; + + it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to SHARDING on success', async function () { + const store = createStore({ + authenticatedFetch: () => + createJsonResponse({ + geoSharding: { customZoneMapping: {}, managedNamespaces: [] }, + }), + }); + + const promise = store.dispatch(createShardKey(shardKeyData)); + expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + + await promise; + expect(store.getState().status).to.equal('SHARDING'); + }); + + it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to UNSHARDED on failure', async function () { + const store = createStore({ + authenticatedFetch: () => Promise.reject(new Error('error')), + }); + + const promise = store.dispatch(createShardKey(shardKeyData)); + expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING'); + + await promise; + expect(store.getState().status).to.equal('UNSHARDED'); + }); + + it('sends correct data to the server when creating a shard key', async function () { + const alreadyManagedNamespaces = [ + { + db: 'test', + collection: 'one', + customShardKey: 'test', + isCustomShardKeyHashed: true, + isShardKeyUnique: false, + numInitialChunks: 1, + presplitHashedZones: true, + }, + ]; + + const getClusterInfoApiResponse = createJsonResponse({ + geoSharding: { + customZoneMapping: {}, + managedNamespaces: alreadyManagedNamespaces, + }, + }); + + // We call cluster API when store is activated to get the initial state. + // When creating a shard key, we call the same API to fetch the latest list of + // managed namespaces & then send it to the server along with the shard key data. + // So, we mock first and second call with same data. And then third call + // should be to create the shard key. + const fetchStub = sinon + .stub() + .onFirstCall() + .returns(getClusterInfoApiResponse) + .onSecondCall() + .returns(getClusterInfoApiResponse) + .onThirdCall() + .resolves(); + + const store = createStore({ + authenticatedFetch: fetchStub, + }); + + await store.dispatch(createShardKey(shardKeyData)); + + const options = fetchStub.getCall(2).args[1]; + expect(options.method).to.equal('PATCH'); + expect(JSON.parse(options.body)).to.deep.equal({ + customZoneMapping: {}, + managedNamespaces: [ + ...alreadyManagedNamespaces, + { ...shardKeyData, db: DB, collection: COLL }, + ], + }); + }); + }); + }); +}); diff --git a/packages/compass-global-writes/src/store/index.ts b/packages/compass-global-writes/src/store/index.ts index 1ddb8a33263..334034a52a6 100644 --- a/packages/compass-global-writes/src/store/index.ts +++ b/packages/compass-global-writes/src/store/index.ts @@ -1,4 +1,4 @@ -import { createStore, applyMiddleware, type Action } from 'redux'; +import { createStore, applyMiddleware, type Action, type Store } from 'redux'; import thunk from 'redux-thunk'; import type { ActivateHelpers } from 'hadron-app-registry'; import type { Logger } from '@mongodb-js/compass-logging'; @@ -6,9 +6,13 @@ import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import type { CollectionTabPluginMetadata } from '@mongodb-js/compass-collection'; import type { AtlasService } from '@mongodb-js/atlas-service/provider'; -import type { ThunkAction } from 'redux-thunk'; +import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; -import reducer, { ShardingStatuses, type RootState } from './reducer'; +import reducer, { + ShardingStatuses, + fetchClusterShardingData, + type RootState, +} from './reducer'; import { AtlasGlobalWritesService } from '../services/atlas-global-writes-service'; type GlobalWritesExtraArgs = { @@ -24,15 +28,24 @@ export type GlobalWritesThunkAction = ThunkAction< GlobalWritesExtraArgs, A >; +export type GlobalWritesThunkDispatch = + ThunkDispatch; -type GlobalWritesPluginOptions = CollectionTabPluginMetadata; -type GlobalWritesPluginServices = Pick< +export type GlobalWritesPluginOptions = Pick< + CollectionTabPluginMetadata, + 'namespace' +>; +export type GlobalWritesPluginServices = Pick< GlobalWritesExtraArgs, 'logger' | 'track' | 'connectionInfoRef' > & { atlasService: AtlasService; }; +export type GlobalWritesStore = Store & { + dispatch: GlobalWritesThunkDispatch; +}; + export function activateGlobalWritesPlugin( options: GlobalWritesPluginOptions, { @@ -44,7 +57,7 @@ export function activateGlobalWritesPlugin( { cleanup }: ActivateHelpers ) { const atlasGlobalWritesService = new AtlasGlobalWritesService(atlasService); - const store = createStore( + const store: GlobalWritesStore = createStore( reducer, { namespace: options.namespace, @@ -61,5 +74,7 @@ export function activateGlobalWritesPlugin( ) ); + void store.dispatch(fetchClusterShardingData()); + return { store, deactivate: () => cleanup() }; } diff --git a/packages/compass-global-writes/src/store/reducer.ts b/packages/compass-global-writes/src/store/reducer.ts index 5555f3ac410..17e1dba65db 100644 --- a/packages/compass-global-writes/src/store/reducer.ts +++ b/packages/compass-global-writes/src/store/reducer.ts @@ -1,16 +1,77 @@ import type { Action, Reducer } from 'redux'; +import type { GlobalWritesThunkAction } from '.'; +import { openToast } from '@mongodb-js/compass-components'; +import type { ManagedNamespace } from '../services/atlas-global-writes-service'; + +export function isAction( + action: Action, + type: A['type'] +): action is A { + return action.type === type; +} + +export type CreateShardKeyData = Pick< + ManagedNamespace, + | 'customShardKey' + | 'isCustomShardKeyHashed' + | 'isShardKeyUnique' + | 'numInitialChunks' + | 'presplitHashedZones' +>; + +enum GlobalWritesActionTypes { + IsManagedNamespaceFetched = 'global-writes/IsManagedNamespaceFetched', + SubmittingForShardingStarted = 'global-writes/SubmittingForShardingStarted', + SubmittingForShardingFinished = 'global-writes/SubmittingForShardingFinished', + SubmittingForShardingErrored = 'global-writes/SubmittingForShardingErrored', +} + +type IsManagedNamespaceFetchedAction = { + type: GlobalWritesActionTypes.IsManagedNamespaceFetched; + isNamespaceManaged: boolean; +}; + +type SubmittingForShardingStartedAction = { + type: GlobalWritesActionTypes.SubmittingForShardingStarted; +}; + +type SubmittingForShardingFinishedAction = { + type: GlobalWritesActionTypes.SubmittingForShardingFinished; +}; + +type SubmittingForShardingErroredAction = { + type: GlobalWritesActionTypes.SubmittingForShardingErrored; +}; export enum ShardingStatuses { /** * Initial status, no information available yet. */ NOT_READY = 'NOT_READY', + + /** + * Namespace is not geo-sharded. + */ + UNSHARDED = 'UNSHARDED', + + /** + * State when user submits namespace to be sharded and + * we are waiting for server to accept the request. + */ + SUBMITTING_FOR_SHARDING = 'SUBMITTING_FOR_SHARDING', + + /** + * Namespace is being sharded. + */ + SHARDING = 'SHARDING', } +export type ShardingStatus = keyof typeof ShardingStatuses; + export type RootState = { namespace: string; isNamespaceSharded: boolean; - status: keyof typeof ShardingStatuses; + status: ShardingStatus; }; const initialState: RootState = { @@ -19,8 +80,165 @@ const initialState: RootState = { status: ShardingStatuses.NOT_READY, }; -const reducer: Reducer = (state = initialState) => { +const reducer: Reducer = (state = initialState, action) => { + if ( + isAction( + action, + GlobalWritesActionTypes.IsManagedNamespaceFetched + ) + ) { + return { + ...state, + isNamespaceSharded: action.isNamespaceManaged, + status: !action.isNamespaceManaged + ? ShardingStatuses.UNSHARDED + : state.status, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingStarted + ) + ) { + return { + ...state, + status: ShardingStatuses.SUBMITTING_FOR_SHARDING, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingFinished + ) + ) { + return { + ...state, + isNamespaceSharded: true, + status: ShardingStatuses.SHARDING, + }; + } + + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingErrored + ) + ) { + return { + ...state, + status: ShardingStatuses.UNSHARDED, + }; + } + return state; }; +export const fetchClusterShardingData = + (): GlobalWritesThunkAction, IsManagedNamespaceFetchedAction> => + async ( + dispatch, + getState, + { atlasGlobalWritesService, connectionInfoRef, logger } + ) => { + if (!connectionInfoRef.current.atlasMetadata) { + return; + } + + const { namespace } = getState(); + const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata; + + try { + // Call the API to check if the namespace is managed. If the namespace is managed, + // we would want to fetch more data that is needed to figure out the state and + // accordingly show the UI to the user. + const isNamespaceManaged = + await atlasGlobalWritesService.isNamespaceManaged(namespace, { + projectId, + clusterName, + }); + + dispatch({ + type: GlobalWritesActionTypes.IsManagedNamespaceFetched, + isNamespaceManaged, + }); + if (!isNamespaceManaged) { + return; + } + // TODO (COMPASS-8277): Now fetch the sharding key and possible process error. + } catch (error) { + logger.log.error( + logger.mongoLogId(1_001_000_330), + 'AtlasFetchError', + 'Error fetching cluster sharding data', + (error as Error).message + ); + openToast('global-writes-fetch-shard-info-error', { + title: `Failed to fetch sharding information: ${ + (error as Error).message + }`, + dismissible: true, + timeout: 5000, + variant: 'important', + }); + } + }; + +export const createShardKey = + ( + data: CreateShardKeyData + ): GlobalWritesThunkAction< + Promise, + | SubmittingForShardingStartedAction + | SubmittingForShardingFinishedAction + | SubmittingForShardingErroredAction + > => + async ( + dispatch, + getState, + { connectionInfoRef, atlasGlobalWritesService, logger } + ) => { + if (!connectionInfoRef.current.atlasMetadata) { + return; + } + + const { namespace } = getState(); + const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata; + + dispatch({ + type: GlobalWritesActionTypes.SubmittingForShardingStarted, + }); + + try { + await atlasGlobalWritesService.createShardKey(namespace, data, { + projectId, + clusterName, + }); + dispatch({ + type: GlobalWritesActionTypes.SubmittingForShardingFinished, + }); + } catch (error) { + logger.log.error( + logger.mongoLogId(1_001_000_331), + 'AtlasFetchError', + 'Error creating cluster shard key', + { + error: (error as Error).message, + data, + } + ); + openToast('global-writes-create-shard-key-error', { + title: `Failed to create shard key: ${(error as Error).message}`, + dismissible: true, + timeout: 5000, + variant: 'important', + }); + dispatch({ + type: GlobalWritesActionTypes.SubmittingForShardingErrored, + }); + } + }; + export default reducer; diff --git a/packages/compass-global-writes/tests/cluster-api-response.json b/packages/compass-global-writes/tests/cluster-api-response.json new file mode 100644 index 00000000000..1f8c12ab7ed --- /dev/null +++ b/packages/compass-global-writes/tests/cluster-api-response.json @@ -0,0 +1,17 @@ +{ + "geoSharding": { + "customZoneMapping": {}, + "managedNamespaces": [ + { + "collection": "listingsAndReviews", + "customShardKey": "address.country", + "db": "sample_airbnb", + "isCustomShardKeyHashed": false, + "isShardKeyUnique": false, + "numInitialChunks": null, + "presplitHashedZones": false + } + ], + "selfManagedSharding": false + } +} diff --git a/packages/compass-global-writes/tests/create-store.tsx b/packages/compass-global-writes/tests/create-store.tsx new file mode 100644 index 00000000000..0aad67f78dc --- /dev/null +++ b/packages/compass-global-writes/tests/create-store.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import type { + GlobalWritesPluginOptions, + GlobalWritesPluginServices, +} from '../src/store'; +import { activateGlobalWritesPlugin } from '../src/store'; +import { createActivateHelpers } from 'hadron-app-registry'; +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; +import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import type { AtlasService } from '@mongodb-js/atlas-service/provider'; +import { Provider } from 'react-redux'; +import { render } from '@mongodb-js/testing-library-compass'; + +import clusterApiResponse from './cluster-api-response.json'; + +const atlasService = { + cloudEndpoint: (p: string) => { + return `https://example.com/${p}`; + }, + authenticatedFetch: (url: RequestInfo | URL) => { + if (url.toString().endsWith('nds/clusters/Project0/Cluster0')) { + return Promise.resolve({ + status: 200, + // eslint-disable-next-line @typescript-eslint/require-await + json: async () => clusterApiResponse, + } as Response); + } + return Promise.resolve({ + status: 200, + // eslint-disable-next-line @typescript-eslint/require-await + json: async () => ({}), + } as Response); + }, +} as unknown as AtlasService; + +export const setupStore = ( + options: Partial = {}, + services: Partial = {} +) => { + const connectionInfoRef = { + current: { + id: 'TEST', + atlasMetadata: { + clusterName: 'Cluster0', + clusterType: 'GEOSHARDED', + projectId: 'Project0', + }, + }, + } as ConnectionInfoRef; + + return activateGlobalWritesPlugin( + { + namespace: 'airbnb.listings', + ...options, + }, + { + logger: createNoopLogger('TEST'), + track: createNoopTrack(), + connectionInfoRef, + ...services, + atlasService: { + ...atlasService, + ...services.atlasService, + } as AtlasService, + }, + createActivateHelpers() + ).store; +}; + +export const renderWithStore = ( + component: JSX.Element, + services: Partial = {}, + options: Partial = {} +) => { + const store = setupStore(options, services); + render({component}); + return store; +};