diff --git a/packages/compass-global-writes/src/components/common-styles.ts b/packages/compass-global-writes/src/components/common-styles.ts new file mode 100644 index 00000000000..b9ec0bd7e84 --- /dev/null +++ b/packages/compass-global-writes/src/components/common-styles.ts @@ -0,0 +1,19 @@ +import { css, spacing } from '@mongodb-js/compass-components'; + +export const bannerStyles = css({ + textAlign: 'justify', +}); + +export const paragraphStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +export const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], + marginBottom: spacing[400], + textAlign: 'justify', +}); diff --git a/packages/compass-global-writes/src/components/create-shard-key-form.spec.tsx b/packages/compass-global-writes/src/components/create-shard-key-form.spec.tsx new file mode 100644 index 00000000000..ce3ccd02ac7 --- /dev/null +++ b/packages/compass-global-writes/src/components/create-shard-key-form.spec.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { + CreateShardKeyForm, + type CreateShardKeyFormProps, +} from './create-shard-key-form'; +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('CreateShardKeyForm', function () { + let onCreateShardKeySpy: sinon.SinonSpy; + context('default', function () { + beforeEach(async function () { + onCreateShardKeySpy = sinon.spy(); + await 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, + }); + }); + }); + + it('cannot be submitted when already submitting', async function () { + onCreateShardKeySpy = sinon.spy(); + await renderWithProps({ + onCreateShardKey: onCreateShardKeySpy, + isSubmittingForSharding: true, + }); + setShardingKeyFieldValue('name'); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.false; + }); + + it('cannot be submitted when cancelling', async function () { + onCreateShardKeySpy = sinon.spy(); + await renderWithProps({ + onCreateShardKey: onCreateShardKeySpy, + isCancellingSharding: true, + }); + setShardingKeyFieldValue('name'); + + userEvent.click(screen.getByTestId('shard-collection-button')); + + expect(onCreateShardKeySpy.calledOnce).to.be.false; + }); +}); diff --git a/packages/compass-global-writes/src/components/create-shard-key-form.tsx b/packages/compass-global-writes/src/components/create-shard-key-form.tsx new file mode 100644 index 00000000000..19c801e7b1d --- /dev/null +++ b/packages/compass-global-writes/src/components/create-shard-key-form.tsx @@ -0,0 +1,336 @@ +import React, { useCallback, useState } from 'react'; +import { + Accordion, + Body, + Button, + Checkbox, + ComboboxOption, + ComboboxWithCustomOption, + css, + cx, + InlineInfoLink, + Label, + Link, + Radio, + RadioGroup, + spacing, + SpinLoader, + Subtitle, + TextInput, +} from '@mongodb-js/compass-components'; +import { + createShardKey, + type RootState, + ShardingStatuses, + type CreateShardKeyData, +} from '../store/reducer'; +import { useAutocompleteFields } from '@mongodb-js/compass-field-store'; +import { connect } from 'react-redux'; +import { containerStyles } from './common-styles'; + +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], +}); + +const nbsp = '\u00a0'; + +type ShardingAdvancedOption = 'default' | 'unique-index' | 'hashed-index'; + +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. + +
+ ); +} + +export type CreateShardKeyFormProps = { + namespace: string; + isSubmittingForSharding: boolean; + isCancellingSharding: boolean; + onCreateShardKey: (data: CreateShardKeyData) => void; +}; + +export function CreateShardKeyForm({ + namespace, + isSubmittingForSharding, + isCancellingSharding, + onCreateShardKey, +}: CreateShardKeyFormProps) { + 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. +
+
+ )} +
+
+ +
+
+ + ); +} + +export default connect( + (state: RootState) => { + return { + namespace: state.namespace, + isSubmittingForSharding: [ + ShardingStatuses.SUBMITTING_FOR_SHARDING, + ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR, + ].includes(state.status), + isCancellingSharding: [ + ShardingStatuses.CANCELLING_SHARDING, + ShardingStatuses.CANCELLING_SHARDING_ERROR, + ].includes(state.status), + }; + }, + { + onCreateShardKey: createShardKey, + } +)(CreateShardKeyForm); diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx index dd6ea046be4..f27f9c2a989 100644 --- a/packages/compass-global-writes/src/components/index.tsx +++ b/packages/compass-global-writes/src/components/index.tsx @@ -14,6 +14,7 @@ import ShardingState from './states/sharding'; import ShardKeyCorrect from './states/shard-key-correct'; import ShardKeyInvalid from './states/shard-key-invalid'; import ShardKeyMismatch from './states/shard-key-mismatch'; +import ShardingError from './states/sharding-error'; const containerStyles = css({ paddingLeft: spacing[400], @@ -66,6 +67,14 @@ function ShardingStateView({ return ; } + if ( + shardingStatus === ShardingStatuses.SHARDING_ERROR || + shardingStatus === ShardingStatuses.CANCELLING_SHARDING_ERROR || + shardingStatus === ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR + ) { + return ; + } + if ( shardingStatus === ShardingStatuses.SHARD_KEY_CORRECT || shardingStatus === ShardingStatuses.UNMANAGING_NAMESPACE diff --git a/packages/compass-global-writes/src/components/shard-zones-table.tsx b/packages/compass-global-writes/src/components/shard-zones-table.tsx index 3453c36b613..6d3952235d0 100644 --- a/packages/compass-global-writes/src/components/shard-zones-table.tsx +++ b/packages/compass-global-writes/src/components/shard-zones-table.tsx @@ -122,7 +122,7 @@ export function ShardZonesTable({ const handleSearchTextChange = useCallback( (e: React.ChangeEvent) => { - tableRef.current.setGlobalFilter(e.currentTarget.value); + tableRef.current.setGlobalFilter(e.currentTarget?.value || ''); }, [tableRef] ); diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx index a54ea1b3180..12e7f64f313 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx @@ -11,6 +11,7 @@ import { Label, Button, ButtonVariant, + SpinLoader, } from '@mongodb-js/compass-components'; import { connect } from 'react-redux'; import { @@ -24,28 +25,20 @@ import toNS from 'mongodb-ns'; import { ShardZonesTable } from '../shard-zones-table'; import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; import ShardKeyMarkup from '../shard-key-markup'; +import { + containerStyles, + paragraphStyles, + bannerStyles, +} from '../common-styles'; const nbsp = '\u00a0'; -const containerStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[400], - marginBottom: spacing[400], -}); - const codeBlockContainerStyles = css({ display: 'flex', flexDirection: 'column', gap: spacing[100], }); -const paragraphStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[100], -}); - export type ShardKeyCorrectProps = { namespace: string; shardKey: ShardKey; @@ -81,7 +74,7 @@ export function ShardKeyCorrect({ return (
- + All documents in your collection should contain both the ‘location’ field (with a ISO country or subdivision code) and your{' '} @@ -175,6 +168,7 @@ export function ShardKeyCorrect({ onClick={onUnmanageNamespace} variant={ButtonVariant.Primary} isLoading={isUnmanagingNamespace} + loadingIndicator={} > Unmanage collection diff --git a/packages/compass-global-writes/src/components/states/shard-key-invalid.tsx b/packages/compass-global-writes/src/components/states/shard-key-invalid.tsx index 3ee10060743..6cfcda3a531 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-invalid.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-invalid.tsx @@ -8,13 +8,7 @@ import React from 'react'; import ShardKeyMarkup from '../shard-key-markup'; import type { RootState, ShardKey } from '../../store/reducer'; import { connect } from 'react-redux'; - -const containerStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[400], - marginBottom: spacing[400], -}); +import { containerStyles, bannerStyles } from '../common-styles'; const paragraphStyles = css({ display: 'flex', @@ -30,7 +24,7 @@ export interface ShardKeyInvalidProps { export function ShardKeyInvalid({ shardKey, namespace }: ShardKeyInvalidProps) { return (
- + To configure Global Writes, the first shard key of this collection must be "location" with ranged sharding and you must also diff --git a/packages/compass-global-writes/src/components/states/shard-key-mismatch.tsx b/packages/compass-global-writes/src/components/states/shard-key-mismatch.tsx index 78ee1fc4091..33d3b87be2d 100644 --- a/packages/compass-global-writes/src/components/states/shard-key-mismatch.tsx +++ b/packages/compass-global-writes/src/components/states/shard-key-mismatch.tsx @@ -5,6 +5,7 @@ import { spacing, css, ButtonVariant, + SpinLoader, } from '@mongodb-js/compass-components'; import React from 'react'; import ShardKeyMarkup from '../shard-key-markup'; @@ -16,13 +17,7 @@ import { } from '../../store/reducer'; import { connect } from 'react-redux'; import type { ManagedNamespace } from '../../services/atlas-global-writes-service'; - -const containerStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[400], - marginBottom: spacing[400], -}); +import { containerStyles, bannerStyles } from '../common-styles'; const unmanageBtnStyles = css({ marginTop: spacing[100], @@ -61,7 +56,7 @@ export function ShardKeyMismatch({ }: ShardKeyMismatchProps) { return (
- + Your requested shard key cannot be configured because the collection has already been sharded with a different key. @@ -75,6 +70,7 @@ export function ShardKeyMismatch({ onClick={onUnmanageNamespace} variant={ButtonVariant.Default} isLoading={isUnmanagingNamespace} + loadingIndicator={} className={unmanageBtnStyles} > Unmanage collection diff --git a/packages/compass-global-writes/src/components/states/sharding-error.spec.tsx b/packages/compass-global-writes/src/components/states/sharding-error.spec.tsx new file mode 100644 index 00000000000..2b1058b379e --- /dev/null +++ b/packages/compass-global-writes/src/components/states/sharding-error.spec.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { expect } from 'chai'; +import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { ShardingError } from './sharding-error'; +import { renderWithStore } from '../../../tests/create-store'; +import Sinon from 'sinon'; + +const shardingError = 'This is an error'; +function renderWithProps( + props?: Partial> +) { + return renderWithStore( + {}} + {...props} + /> + ); +} + +describe('ShardingError', function () { + it('renders the error', async function () { + await renderWithProps(); + expect(screen.getByText(/There was an error sharding your collection/)).to + .be.visible; + expect(screen.getByText(shardingError)).to.be.visible; + }); + + it('includes a button to cancel sharding', async function () { + const onCancelSharding = Sinon.spy(); + await renderWithProps({ onCancelSharding }); + const btn = screen.getByRole('button', { name: 'Cancel Request' }); + expect(btn).to.be.visible; + + userEvent.click(btn); + expect(onCancelSharding).to.have.been.called; + }); + + it('the cancel sharding button is disabled when cancelling is in progress', async function () { + const onCancelSharding = Sinon.spy(); + await renderWithProps({ onCancelSharding, isCancellingSharding: true }); + const btn = screen.getByTestId('cancel-sharding-btn'); + + userEvent.click(btn); + expect(onCancelSharding).not.to.have.been.called; + }); + + it('the cancel sharding button is disabled also when sharding is in progress', async function () { + const onCancelSharding = Sinon.spy(); + await renderWithProps({ onCancelSharding, isSubmittingForSharding: true }); + const btn = screen.getByTestId('cancel-sharding-btn'); + + userEvent.click(btn); + expect(onCancelSharding).not.to.have.been.called; + }); + + it('includes the createShardKeyForm', async function () { + await renderWithProps(); + expect(screen.getByRole('button', { name: 'Shard Collection' })).to.be + .visible; + }); +}); diff --git a/packages/compass-global-writes/src/components/states/sharding-error.tsx b/packages/compass-global-writes/src/components/states/sharding-error.tsx new file mode 100644 index 00000000000..a71c2d583b6 --- /dev/null +++ b/packages/compass-global-writes/src/components/states/sharding-error.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { + Banner, + BannerVariant, + Button, + css, + spacing, + SpinLoader, +} from '@mongodb-js/compass-components'; +import { connect } from 'react-redux'; +import { + cancelSharding, + type RootState, + ShardingStatuses, +} from '../../store/reducer'; +import CreateShardKeyForm from '../create-shard-key-form'; +import { containerStyles, bannerStyles } from '../common-styles'; + +const btnStyles = css({ + float: 'right', + height: spacing[600], +}); + +const errorStyles = css({ + marginTop: spacing[200], + whiteSpace: 'pre-wrap', + textAlign: 'left', +}); + +interface ShardingErrorProps { + shardingError: string; + isCancellingSharding: boolean; + isSubmittingForSharding: boolean; + onCancelSharding: () => void; +} + +export function ShardingError({ + shardingError, + isCancellingSharding, + isSubmittingForSharding, + onCancelSharding, +}: ShardingErrorProps) { + return ( +
+ + There was an error sharding your collection. Please cancel the request, + make any necessary changes to your collection, and try again. +
{shardingError}
+ +
+ +
+ ); +} + +export default connect( + (state: RootState) => { + if (!state.shardingError) { + throw new Error('No shardingError found in ShardingError component'); + } + return { + shardingError: state.shardingError, + isCancellingSharding: + state.status === ShardingStatuses.CANCELLING_SHARDING_ERROR, + isSubmittingForSharding: + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR, + }; + }, + { + onCancelSharding: cancelSharding, + } +)(ShardingError); diff --git a/packages/compass-global-writes/src/components/states/sharding.tsx b/packages/compass-global-writes/src/components/states/sharding.tsx index 3bef1cf75d8..914e3197080 100644 --- a/packages/compass-global-writes/src/components/states/sharding.tsx +++ b/packages/compass-global-writes/src/components/states/sharding.tsx @@ -7,6 +7,7 @@ import { css, Link, spacing, + SpinLoader, } from '@mongodb-js/compass-components'; import { connect } from 'react-redux'; import { @@ -14,15 +15,10 @@ import { type RootState, ShardingStatuses, } from '../../store/reducer'; +import { containerStyles, bannerStyles } from '../common-styles'; const nbsp = '\u00a0'; -const containerStyles = css({ - display: 'flex', - flexDirection: 'column', - gap: spacing[400], -}); - const btnStyles = css({ float: 'right', height: spacing[600], @@ -39,7 +35,7 @@ export function ShardingState({ }: ShardingStateProps) { return (
- + Sharding your collection … {nbsp}this should not take too long. diff --git a/packages/compass-global-writes/src/components/states/unsharded.tsx b/packages/compass-global-writes/src/components/states/unsharded.tsx index 073929dfe0d..43f05af7742 100644 --- a/packages/compass-global-writes/src/components/states/unsharded.tsx +++ b/packages/compass-global-writes/src/components/states/unsharded.tsx @@ -1,329 +1,14 @@ -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'; +import React from 'react'; +import { Banner, BannerVariant } from '@mongodb-js/compass-components'; +import CreateShardKeyForm from '../create-shard-key-form'; +import { containerStyles, bannerStyles } from '../common-styles'; 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) { +export function UnshardedState() { return (
- + To use Global Writes, this collection must be configured with a compound shard key made up of both a ‘location’ field and an @@ -331,19 +16,9 @@ export function UnshardedState(props: UnshardedStateProps) { {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); +export default 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 index 74d57ccb925..13c8b9aafcc 100644 --- a/packages/compass-global-writes/src/components/states/usharded.spec.tsx +++ b/packages/compass-global-writes/src/components/states/usharded.spec.tsx @@ -1,36 +1,13 @@ import React from 'react'; import { expect } from 'chai'; -import { screen, userEvent } from '@mongodb-js/testing-library-compass'; +import { screen } 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, - }); + return renderWithStore(); } describe('UnshardedState', function () { @@ -44,150 +21,9 @@ describe('UnshardedState', function () { expect(screen.getByTestId('unsharded-text-description')).to.exist; }); - context('shard collection form', function () { - let onCreateShardKeySpy: sinon.SinonSpy; - beforeEach(async function () { - onCreateShardKeySpy = sinon.spy(); - await 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, - }); - }); + it('includes the createShardKeyForm', async function () { + await renderWithProps(); + expect(screen.getByRole('button', { name: 'Shard Collection' })).to.be + .visible; }); }); 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 80390f1def2..8ce8f1456c7 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 @@ -4,6 +4,8 @@ import type { AtlasService } from '@mongodb-js/atlas-service/provider'; import type { CreateShardKeyData } from '../store/reducer'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +const TIMESTAMP_REGEX = /\[\d{1,2}:\d{2}:\d{2}\.\d{3}\]/; + export type ShardZoneMapping = { isoCode: string; typeOneIsoCode: string; @@ -184,10 +186,13 @@ export class AtlasGlobalWritesService { const namespaceShardingError = data.automationStatus.processes.find( (process) => process.statusType === 'ERROR' && - process.workingOnShort === 'ShardingCollections' && + process.workingOnShort === 'ShardCollections' && process.errorText.indexOf(namespace) !== -1 ); - return namespaceShardingError?.errorText; + if (!namespaceShardingError) return undefined; + const errorTextSplit = + namespaceShardingError.errorText.split(TIMESTAMP_REGEX); + return errorTextSplit[errorTextSplit.length - 1].trim(); } async getShardingKeys(namespace: string) { diff --git a/packages/compass-global-writes/src/store/index.spec.ts b/packages/compass-global-writes/src/store/index.spec.ts index ee001aeb6f2..a32c7d032ff 100644 --- a/packages/compass-global-writes/src/store/index.spec.ts +++ b/packages/compass-global-writes/src/store/index.spec.ts @@ -51,8 +51,8 @@ const managedNamespace: ManagedNamespace = { const failedShardingProcess: AutomationAgentProcess = { statusType: 'ERROR', - workingOnShort: 'ShardingCollections', - errorText: `Failed to shard ${NS}`, + workingOnShort: 'ShardCollections', + errorText: `before timestamp[01:02:03.456]Failed to shard ${NS}`, }; function createAuthFetchResponse< @@ -230,6 +230,9 @@ describe('GlobalWritesStore Store', function () { clock.tick(POLLING_INTERVAL); await waitFor(() => { expect(store.getState().status).to.equal('SHARDING_ERROR'); + expect(store.getState().shardingError).to.equal( + `Failed to shard ${NS}` + ); // the original error text was: `before timestamp[01:02:03.456]Failed to shard ${NS}` }); }); @@ -392,6 +395,7 @@ describe('GlobalWritesStore Store', function () { }, unique: true, }), + hasShardingError: () => true, // mismatch will also trigger an error }); await waitFor(() => { expect(store.getState().status).to.equal('SHARD_KEY_MISMATCH'); @@ -409,6 +413,7 @@ describe('GlobalWritesStore Store', function () { }, unique: false, // this does not match }), + hasShardingError: () => true, // mismatch will also trigger an error }); await waitFor(() => { expect(store.getState().status).to.equal('SHARD_KEY_MISMATCH'); @@ -442,15 +447,61 @@ describe('GlobalWritesStore Store', function () { }); }); - it('sharding error', async function () { + it('sharding error -> cancelling request -> not managed', async function () { + // initial state === sharding error + let mockManagedNamespace = true; + let mockShardingError = true; + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + const store = createStore({ + isNamespaceManaged: Sinon.fake(() => mockManagedNamespace), + hasShardingError: Sinon.fake(() => mockShardingError), + }); + await waitFor(() => { + expect(store.getState().status).to.equal('SHARDING_ERROR'); + expect(store.getState().managedNamespace).to.equal(managedNamespace); + }); + + // user triggers a cancellation + const promise = store.dispatch(cancelSharding()); + mockManagedNamespace = false; + mockShardingError = false; + await promise; + expect(store.getState().status).to.equal('UNSHARDED'); + expect(confirmationStub).to.have.been.called; + }); + + it('sharding error -> submitting form -> sharding -> sharded', async function () { + // initial state === sharding error= + let mockShardingError = true; + let mockShardKey = false; + clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); const store = createStore({ isNamespaceManaged: () => true, - hasShardingError: () => true, + hasShardingError: Sinon.fake(() => mockShardingError), + hasShardKey: Sinon.fake(() => mockShardKey), }); await waitFor(() => { expect(store.getState().status).to.equal('SHARDING_ERROR'); expect(store.getState().managedNamespace).to.equal(managedNamespace); }); + + // user submits the form + const promise = store.dispatch(createShardKey(shardKeyData)); + mockShardingError = false; + expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING_ERROR'); + await promise; + expect(store.getState().status).to.equal('SHARDING'); + + // the key is created + mockShardKey = true; + clock.tick(POLLING_INTERVAL); + await waitFor(() => { + expect(store.getState().status).to.equal('SHARD_KEY_CORRECT'); + }); }); it('sends correct data to the server when creating a shard key', async function () { diff --git a/packages/compass-global-writes/src/store/reducer.ts b/packages/compass-global-writes/src/store/reducer.ts index 4255bb49072..97b6d18b3c8 100644 --- a/packages/compass-global-writes/src/store/reducer.ts +++ b/packages/compass-global-writes/src/store/reducer.ts @@ -130,6 +130,7 @@ export enum ShardingStatuses { * we are waiting for server to accept the request. */ SUBMITTING_FOR_SHARDING = 'SUBMITTING_FOR_SHARDING', + SUBMITTING_FOR_SHARDING_ERROR = 'SUBMITTING_FOR_SHARDING_ERROR', /** * Namespace is being sharded. @@ -141,6 +142,7 @@ export enum ShardingStatuses { * we are waiting for server to accept the request. */ CANCELLING_SHARDING = 'CANCELLING_SHARDING', + CANCELLING_SHARDING_ERROR = 'CANCELLING_SHARDING_ERROR', /** * Sharding failed. @@ -224,7 +226,10 @@ export type RootState = { pollingTimeout?: NodeJS.Timeout; } | { - status: ShardingStatuses.SHARDING_ERROR; + status: + | ShardingStatuses.SHARDING_ERROR + | ShardingStatuses.CANCELLING_SHARDING_ERROR + | ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR; shardKey?: never; shardingError: string; pollingTimeout?: never; @@ -330,16 +335,31 @@ const reducer: Reducer = (state = initialState, action) => { }; } + if ( + isAction( + action, + GlobalWritesActionTypes.SubmittingForShardingStarted + ) && + state.status === ShardingStatuses.SHARDING_ERROR + ) { + return { + ...state, + status: ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR, + }; + } + if ( isAction( action, GlobalWritesActionTypes.SubmittingForShardingFinished ) && (state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING || + state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING_ERROR || state.status === ShardingStatuses.NOT_READY) ) { return { ...state, + shardingError: undefined, managedNamespace: action.managedNamespace || state.managedNamespace, status: ShardingStatuses.SHARDING, }; @@ -388,15 +408,30 @@ const reducer: Reducer = (state = initialState, action) => { }; } + if ( + isAction( + action, + GlobalWritesActionTypes.CancellingShardingStarted + ) && + state.status === ShardingStatuses.SHARDING_ERROR + ) { + return { + ...state, + status: ShardingStatuses.CANCELLING_SHARDING_ERROR, + }; + } + if ( isAction( action, GlobalWritesActionTypes.CancellingShardingErrored ) && - state.status === ShardingStatuses.CANCELLING_SHARDING + (state.status === ShardingStatuses.CANCELLING_SHARDING || + state.status === ShardingStatuses.CANCELLING_SHARDING_ERROR) ) { return { ...state, + shardingError: undefined, status: ShardingStatuses.SHARDING, }; } @@ -407,7 +442,8 @@ const reducer: Reducer = (state = initialState, action) => { GlobalWritesActionTypes.CancellingShardingFinished ) && (state.status === ShardingStatuses.CANCELLING_SHARDING || - state.status === ShardingStatuses.SHARDING_ERROR) + state.status === ShardingStatuses.SHARDING_ERROR || + state.status === ShardingStatuses.CANCELLING_SHARDING_ERROR) // the error might come before the cancel request was processed ) { return { @@ -696,7 +732,10 @@ export const fetchNamespaceShardKey = (): GlobalWritesThunkAction< atlasGlobalWritesService.getShardingKeys(namespace), ]); - if (shardingError) { + if (shardingError && !shardKey) { + // if there is an existing shard key and an error both, + // means we have a key mismatch + // this will be handled in NamespaceShardKeyFetched if (status === ShardingStatuses.SHARDING) { dispatch(stopPollingForShardKey()); }