From d3d1c1d3cacc133f6181b84cd4c74f1334a3ce17 Mon Sep 17 00:00:00 2001 From: Bailey Pearson Date: Wed, 29 Nov 2023 08:07:28 -0700 Subject: [PATCH] feat(databases-collections, compass-sidebar): add rename collection flow COMPASS-5704 (#5063) --- package-lock.json | 2 + .../src/stores/instance-store.ts | 18 + .../compass-collection/src/modules/tabs.ts | 38 ++ .../src/stores/tabs.spec.ts | 1 + .../compass-collection/src/stores/tabs.ts | 20 + .../src/hooks/use-toast.tsx | 2 +- .../compass-databases-navigation/package.json | 1 + .../src/collection-item.tsx | 25 +- .../src/constants.tsx | 3 +- .../src/databases-navigation-tree.spec.tsx | 59 +++ .../commands/save-aggregation-pipeline.ts | 50 +++ .../compass-e2e-tests/helpers/insert-data.ts | 12 +- .../compass-e2e-tests/helpers/selectors.ts | 19 + .../tests/collection-aggregations-tab.test.ts | 46 +- .../tests/collection-rename.test.ts | 393 ++++++++++++++++++ packages/compass-home/src/components/home.tsx | 2 + .../src/feature-flags.ts | 12 + .../sidebar-databases-navigation.tsx | 3 + .../data-service/src/data-service.spec.ts | 39 +- packages/data-service/src/data-service.ts | 17 + .../rename-collection-modal.spec.tsx | 86 ++++ .../rename-collection-modal.tsx | 153 +++++++ packages/databases-collections/src/index.ts | 14 + .../rename-collection.spec.ts | 99 +++++ .../rename-collection/rename-collection.ts | 133 ++++++ .../src/stores/rename-collection.spec.tsx | 37 ++ .../src/stores/rename-collection.ts | 40 ++ .../src/pipeline-storage.ts | 12 +- 28 files changed, 1278 insertions(+), 58 deletions(-) create mode 100644 packages/compass-e2e-tests/helpers/commands/save-aggregation-pipeline.ts create mode 100644 packages/compass-e2e-tests/tests/collection-rename.test.ts create mode 100644 packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.spec.tsx create mode 100644 packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.tsx create mode 100644 packages/databases-collections/src/modules/rename-collection/rename-collection.spec.ts create mode 100644 packages/databases-collections/src/modules/rename-collection/rename-collection.ts create mode 100644 packages/databases-collections/src/stores/rename-collection.spec.tsx create mode 100644 packages/databases-collections/src/stores/rename-collection.ts diff --git a/package-lock.json b/package-lock.json index ecd3aab5325..eb13cb0f6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44972,6 +44972,7 @@ "license": "SSPL", "dependencies": { "@mongodb-js/compass-components": "^1.19.0", + "compass-preferences-model": "^2.15.6", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.6" }, @@ -59246,6 +59247,7 @@ "@types/react-window": "^1.8.5", "@types/sinon-chai": "^3.2.5", "chai": "^4.3.4", + "compass-preferences-model": "^2.15.6", "depcheck": "^1.4.1", "eslint": "^7.25.0", "mocha": "^10.2.0", diff --git a/packages/compass-app-stores/src/stores/instance-store.ts b/packages/compass-app-stores/src/stores/instance-store.ts index 04b609acecb..ffb2b4d06ce 100644 --- a/packages/compass-app-stores/src/stores/instance-store.ts +++ b/packages/compass-app-stores/src/stores/instance-store.ts @@ -266,6 +266,24 @@ export function createInstanceStore({ }); onAppRegistryEvent('refresh-databases', onRefreshDatabases); + const onCollectionRenamed = voidify( + async ({ from, to }: { from: string; to: string }) => { + // we must fetch the old collection's metadata before refreshing because refreshing the + // collection metadata will remove the old collection from the model. + const metadata = await fetchCollectionMetadata(from); + appRegistry.emit('refresh-collection-tabs', { + metadata, + newNamespace: to, + }); + const { database } = toNS(from); + await refreshNamespace({ + ns: to, + database, + }); + } + ); + appRegistry.on('collection-renamed', onCollectionRenamed); + // Event emitted when the Collections grid needs to be refreshed // with new collections or collection stats for existing ones. const onRefreshCollections = voidify(async ({ ns }: { ns: string }) => { diff --git a/packages/compass-collection/src/modules/tabs.ts b/packages/compass-collection/src/modules/tabs.ts index ffe6d5dde9a..4094483e760 100644 --- a/packages/compass-collection/src/modules/tabs.ts +++ b/packages/compass-collection/src/modules/tabs.ts @@ -37,6 +37,7 @@ enum CollectionTabsActions { DatabaseDropped = 'compass-collection/DatabaseDropped', DataServiceConnected = 'compass-collection/DataServiceConnected', DataServiceDisconnected = 'compass-collection/DataServiceDisconnected', + CollectionRenamed = 'compass-collection/CollectionRenamed', } type CollectionTabsThunkAction< @@ -182,6 +183,17 @@ const reducer: Reducer = ( tabs: newTabs, }; } + if (action.type === CollectionTabsActions.CollectionRenamed) { + const { tabs } = action; + + const activeTabIndex = getActiveTabIndex(state); + const activeTabId = tabs[activeTabIndex]?.id ?? null; + return { + ...state, + tabs, + activeTabId, + }; + } return state; }; @@ -391,4 +403,30 @@ export const dataServiceDisconnected = () => { return { type: CollectionTabsActions.DataServiceDisconnected }; }; +export const collectionRenamed = ({ + from, + newNamespace, +}: { + from: CollectionMetadata; + newNamespace: string; +}): CollectionTabsThunkAction => { + return (dispatch, getState) => { + const tabs = getState().tabs.map((tab) => + tab.namespace === from.namespace + ? dispatch( + createNewTab({ + ...from, + namespace: newNamespace, + }) + ) + : tab + ); + + dispatch({ + type: CollectionTabsActions.CollectionRenamed, + tabs, + }); + }; +}; + export default reducer; diff --git a/packages/compass-collection/src/stores/tabs.spec.ts b/packages/compass-collection/src/stores/tabs.spec.ts index eb2190c58da..96549287d67 100644 --- a/packages/compass-collection/src/stores/tabs.spec.ts +++ b/packages/compass-collection/src/stores/tabs.spec.ts @@ -324,6 +324,7 @@ describe('Collection Tabs Store', function () { 'select-namespace', 'collection-dropped', 'database-dropped', + 'refresh-collection-tabs', 'data-service-connected', 'data-service-disconnected', 'menu-share-schema-json', diff --git a/packages/compass-collection/src/stores/tabs.ts b/packages/compass-collection/src/stores/tabs.ts index 070afdd831f..3489d6820e4 100644 --- a/packages/compass-collection/src/stores/tabs.ts +++ b/packages/compass-collection/src/stores/tabs.ts @@ -13,8 +13,10 @@ import tabs, { getActiveTab, dataServiceDisconnected, dataServiceConnected, + collectionRenamed, } from '../modules/tabs'; import { globalAppRegistry } from 'hadron-app-registry'; +import type { CollectionMetadata } from 'mongodb-collection-model'; type ThunkExtraArg = { globalAppRegistry: AppRegistry; @@ -81,6 +83,24 @@ export function configureStore({ store.dispatch(databaseDropped(namespace)); }); + globalAppRegistry.on( + 'refresh-collection-tabs', + ({ + metadata, + newNamespace, + }: { + metadata: CollectionMetadata; + newNamespace: string; + }) => { + store.dispatch( + collectionRenamed({ + from: metadata, + newNamespace, + }) + ); + } + ); + /** * Set the data service in the store when connected. */ diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index 8dbee38901f..169be35ff3e 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -21,7 +21,7 @@ const defaultToastProperties: Partial = { dismissible: true, }; -interface ToastActions { +export interface ToastActions { openToast: (id: string, toastProperties: ToastProperties) => void; closeToast: (id: string) => void; } diff --git a/packages/compass-databases-navigation/package.json b/packages/compass-databases-navigation/package.json index ab0741d21db..9667d8284db 100644 --- a/packages/compass-databases-navigation/package.json +++ b/packages/compass-databases-navigation/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@mongodb-js/compass-components": "^1.19.0", + "compass-preferences-model": "^2.15.6", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.6" }, diff --git a/packages/compass-databases-navigation/src/collection-item.tsx b/packages/compass-databases-navigation/src/collection-item.tsx index f03f7163dc3..035861ba6e3 100644 --- a/packages/compass-databases-navigation/src/collection-item.tsx +++ b/packages/compass-databases-navigation/src/collection-item.tsx @@ -21,6 +21,7 @@ import type { NamespaceItemProps, } from './tree-item'; import type { Actions } from './constants'; +import { usePreference } from 'compass-preferences-model'; const CollectionIcon: React.FunctionComponent<{ type: string; @@ -64,6 +65,10 @@ export const CollectionItem: React.FunctionComponent< style, onNamespaceAction, }) => { + const isRenameCollectionEnabled = usePreference( + 'enableRenameCollectionModal', + React + ); const [hoverProps, isHovered] = useHoverState(); const onDefaultAction = useCallback( @@ -121,16 +126,26 @@ export const CollectionItem: React.FunctionComponent< icon: 'Edit', } ); - } else { + + return actions; + } + + if (type !== 'timeseries' && isRenameCollectionEnabled) { actions.push({ - action: 'drop-collection', - label: 'Drop collection', - icon: 'Trash', + action: 'rename-collection', + label: 'Rename collection', + icon: 'Edit', }); } + actions.push({ + action: 'drop-collection', + label: 'Drop collection', + icon: 'Trash', + }); + return actions; - }, [type, isReadOnly]); + }, [type, isReadOnly, isRenameCollectionEnabled]); return ( { + const sandbox = Sinon.createSandbox(); + before(() => { + sandbox.stub(preferencesAccess, 'getPreferences').returns({ + enableRenameCollectionModal: true, + } as any); + }); + + after(() => sandbox.restore()); + + it('shows the Rename Collection action', function () { + render( + {}} + onNamespaceAction={() => {}} + {...TEST_VIRTUAL_PROPS} + > + ); + + const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const showActionsButton = within(collection).getByTitle('Show actions'); + + expect(within(collection).getByTitle('Show actions')).to.exist; + + userEvent.click(showActionsButton); + + expect(screen.getByText('Rename collection')).to.exist; + }); + + it('should activate callback with `rename-collection` when corresponding action is clicked', function () { + const spy = Sinon.spy(); + render( + {}} + {...TEST_VIRTUAL_PROPS} + > + ); + + const collection = screen.getByTestId('sidebar-collection-bar.meow'); + + userEvent.click(within(collection).getByTitle('Show actions')); + userEvent.click(screen.getByText('Rename collection')); + + expect(spy).to.be.calledOnceWithExactly('bar.meow', 'rename-collection'); + }); + }); + it('should render databases', function () { render( screen.getByText('Rename collection')).to.throw; expect(screen.getByText('Drop collection')).to.exist; }); @@ -192,6 +248,9 @@ describe('DatabasesNavigationTree', function () { expect(screen.getByText('Drop view')).to.exist; expect(screen.getByText('Duplicate view')).to.exist; expect(screen.getByText('Modify view')).to.exist; + + // views cannot be renamed + expect(() => screen.getByText('Rename collection')).to.throw; }); }); diff --git a/packages/compass-e2e-tests/helpers/commands/save-aggregation-pipeline.ts b/packages/compass-e2e-tests/helpers/commands/save-aggregation-pipeline.ts new file mode 100644 index 00000000000..caff249266f --- /dev/null +++ b/packages/compass-e2e-tests/helpers/commands/save-aggregation-pipeline.ts @@ -0,0 +1,50 @@ +import { Selectors } from '../compass'; +import type { CompassBrowser } from '../compass-browser'; + +/** + * Saves an aggregation pipeline. + * + * This helper expects that the browser is already on the aggregations tab for the target collection. + */ +export async function saveAggregationPipeline( + browser: CompassBrowser, + aggregationName: string, + pipeline: Record[] +) { + for (let index = 0; index < pipeline.length; index++) { + const stage = pipeline[index]; + const stageOperator = Object.keys(stage)[0]; + const stageValue = stage[stageOperator]; + + // add stage + await browser.clickVisible(Selectors.AddStageButton); + await browser.$(Selectors.stageEditor(index)).waitForDisplayed(); + + await browser.focusStageOperator(index); + await browser.selectStageOperator(index, stageOperator); + await browser.setCodemirrorEditorValue( + Selectors.stageEditor(index), + stageValue + ); + } + + await browser.clickVisible(Selectors.SavePipelineMenuButton); + const menuElement = await browser.$(Selectors.SavePipelineMenuContent); + await menuElement.waitForDisplayed(); + await browser.clickVisible(Selectors.SavePipelineSaveAsAction); + + // wait for the modal to appear + const savePipelineModal = await browser.$(Selectors.SavePipelineModal); + await savePipelineModal.waitForDisplayed(); + + // set aggregation name + await browser.waitForAnimations(Selectors.SavePipelineNameInput); + const pipelineNameInput = await browser.$(Selectors.SavePipelineNameInput); + await pipelineNameInput.setValue(aggregationName); + + const createButton = await browser + .$(Selectors.SavePipelineModal) + .$('button=Save'); + + await createButton.click(); +} diff --git a/packages/compass-e2e-tests/helpers/insert-data.ts b/packages/compass-e2e-tests/helpers/insert-data.ts index b994e6c354a..2018e480495 100644 --- a/packages/compass-e2e-tests/helpers/insert-data.ts +++ b/packages/compass-e2e-tests/helpers/insert-data.ts @@ -3,9 +3,10 @@ import type { Db, MongoServerError } from 'mongodb'; const CONNECTION_URI = 'mongodb://localhost:27091'; -async function dropDatabase(db: Db) { +export async function dropDatabase(db: Db | string) { + const database = typeof db === 'string' ? client.db(db) : db; try { - await db.dropDatabase(); + await database.dropDatabase(); } catch (err) { const codeName = (err as MongoServerError).codeName; if (codeName !== 'NamespaceNotFound') { @@ -14,13 +15,14 @@ async function dropDatabase(db: Db) { } } -async function createBlankCollection(db: Db, name: string) { +export async function createBlankCollection(db: Db | string, name: string) { + const database = typeof db === 'string' ? client.db(db) : db; try { - await db.createCollection(name); + await database.createCollection(name); } catch (err) { const codeName = (err as MongoServerError).codeName; if (codeName === 'NamespaceExists') { - await db.collection(name).deleteMany({}); + await database.collection(name).deleteMany({}); } else { throw err; } diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 57b191e25e6..78c70f4117e 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -218,6 +218,20 @@ export const RemoveConnectionItem = `${ConnectionMenu} [data-testid="connection- export const RecentConnectionsHeader = '[data-testid="recents-header"]'; export const RecentConnections = '[data-testid="recent-connection"]'; +// Rename Collection Modal +export const RenameCollectionModal = '[data-testid="rename-collection-modal"]'; +export const RenameCollectionModalInput = + '[data-testid="rename-collection-name-input"]'; +export const RenameCollectionModalConfirmationScreen = + '[data-testid="rename-collection-confirmation-screen"]'; +export const RenameCollectionModalSuccessToast = + '[data-testid="toast-collection-rename-success"]'; +export const RenameCollectionModalSubmitButton = + '[data-testid="submit-button"]'; +export const RenameCollectionModalErrorBanner = + '[data-testid="rename-collection-modal-error"]'; +export const RenameCollectionModalCloseButton = `${RenameCollectionModal} [aria-label="Close modal"]`; + // Database-Collection Sidebar export const SidebarDatabaseAndCollectionList = '[data-testid="databases-and-collections"]'; @@ -236,6 +250,8 @@ export const CollectionShowActionsButton = '[data-testid="sidebar-collection-item-actions-show-actions"]'; export const DropDatabaseButton = '[data-action="drop-database"]'; export const CreateCollectionButton = '[data-action="create-collection"]'; +export const RenameCollectionButton = + '[data-testid="sidebar-collection-item-actions-rename-collection-action"]'; export const DropCollectionButton = '[data-action="drop-collection"]'; export const FleConnectionConfigurationBanner = '[data-testid="fle-connection-configuration"]'; @@ -701,6 +717,9 @@ export const FavouriteQueriesButton = `${QueryBarHistory} [data-testid="past-que export const FavouriteQueryListItem = `${QueryBarHistory} [data-testid="favorite-query-list-item"]`; export const FavouriteQueryTitle = `${QueryBarHistory} [data-testid="query-history-query-title"]`; +export const QueryHistoryFavoritesButton = `[data-testid="past-queries-favorites"]`; +export const QueryHistoryFavoriteItem = `[data-testid="favorite-query-list-item"]`; + export const myQueriesItem = (title: string): string => { return `[data-testid="my-queries-content"] [title="${title}"]`; }; diff --git a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts index fbe6c6dc993..899ccc41694 100644 --- a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts @@ -16,6 +16,7 @@ import { createNumbersCollection, } from '../helpers/insert-data'; import { getStageOperators } from '../helpers/read-stage-operators'; +import { saveAggregationPipeline } from '../helpers/commands/save-aggregation-pipeline'; const { expect } = chai; @@ -134,49 +135,6 @@ async function switchPipelineMode( await browser.waitForAnimations(Selectors.AggregationBuilderWorkspace); } -async function saveAggregation( - browser: CompassBrowser, - aggregationName: string, - pipeline: Record[] -) { - for (let index = 0; index < pipeline.length; index++) { - const stage = pipeline[index]; - const stageOperator = Object.keys(stage)[0]; - const stageValue = stage[stageOperator]; - - // add stage - await browser.clickVisible(Selectors.AddStageButton); - await browser.$(Selectors.stageEditor(index)).waitForDisplayed(); - - await browser.focusStageOperator(index); - await browser.selectStageOperator(index, stageOperator); - await browser.setCodemirrorEditorValue( - Selectors.stageEditor(index), - stageValue - ); - } - - await browser.clickVisible(Selectors.SavePipelineMenuButton); - const menuElement = await browser.$(Selectors.SavePipelineMenuContent); - await menuElement.waitForDisplayed(); - await browser.clickVisible(Selectors.SavePipelineSaveAsAction); - - // wait for the modal to appear - const savePipelineModal = await browser.$(Selectors.SavePipelineModal); - await savePipelineModal.waitForDisplayed(); - - // set aggregation name - await browser.waitForAnimations(Selectors.SavePipelineNameInput); - const pipelineNameInput = await browser.$(Selectors.SavePipelineNameInput); - await pipelineNameInput.setValue(aggregationName); - - const createButton = await browser - .$(Selectors.SavePipelineModal) - .$('button=Save'); - - await createButton.click(); -} - async function deleteStage( browser: CompassBrowser, index: number @@ -1139,7 +1097,7 @@ describe('Collection aggregations tab', function () { describe('saving pipelines', function () { const name = 'test agg 1'; beforeEach(async function () { - await saveAggregation(browser, name, [ + await saveAggregationPipeline(browser, name, [ { $match: '{ i: 0 }', }, diff --git a/packages/compass-e2e-tests/tests/collection-rename.test.ts b/packages/compass-e2e-tests/tests/collection-rename.test.ts new file mode 100644 index 00000000000..d68b7a188a7 --- /dev/null +++ b/packages/compass-e2e-tests/tests/collection-rename.test.ts @@ -0,0 +1,393 @@ +import { expect } from 'chai'; +import type { Compass } from '../helpers/compass'; +import { beforeTests, afterTests, afterTest } from '../helpers/compass'; +import type { CompassBrowser } from '../helpers/compass-browser'; +import { createBlankCollection, dropDatabase } from '../helpers/insert-data'; +import * as Selectors from '../helpers/selectors'; + +import { setTimeout } from 'timers/promises'; +import { saveAggregationPipeline } from '../helpers/commands/save-aggregation-pipeline'; +import { setFeature } from '../helpers/commands'; +const initialName = 'numbers'; +const newName = 'renamed'; + +const databaseName = 'rename-collection'; + +class RenameCollectionModal { + constructor(private browser: CompassBrowser) {} + get confirmationScreen() { + return this.browser.$(Selectors.RenameCollectionModalConfirmationScreen); + } + get successToast() { + return this.browser.$(Selectors.RenameCollectionModalSuccessToast); + } + get submitButton() { + return this.browser.$(Selectors.RenameCollectionModalSubmitButton); + } + get errorBanner() { + return this.browser.$(Selectors.RenameCollectionModalErrorBanner); + } + get collectionNameInput() { + return this.browser.$(Selectors.RenameCollectionModalInput); + } + get dismissButton() { + return this.browser.$(Selectors.RenameCollectionModalCloseButton); + } + + async isVisible() { + const modal = await this.browser.$(Selectors.RenameCollectionModal); + await modal.waitForDisplayed(); + } + + async isNotVisible() { + const modal = await this.browser.$(Selectors.RenameCollectionModal); + return modal.waitForDisplayed({ + reverse: true, + }); + } + + async enterNewCollectionName(newName: string) { + const input = await this.browser.$(Selectors.RenameCollectionModalInput); + await input.clearValue(); + await input.addValue(newName); + } +} + +async function navigateToCollectionInSidebar(browser: CompassBrowser) { + const sidebar = await browser.$(Selectors.SidebarDatabaseAndCollectionList); + await sidebar.waitForDisplayed(); + + // open the database in the sidebar + const dbElement = await browser.$(Selectors.sidebarDatabase(databaseName)); + await dbElement.waitForDisplayed(); + const button = await browser.$(Selectors.sidebarDatabaseToggle(databaseName)); + + await button.waitForDisplayed(); + await button.click(); + + // wait for the collection to become displayed + const collectionSelector = Selectors.sidebarCollection( + databaseName, + initialName + ); + await browser.scrollToVirtualItem( + Selectors.SidebarDatabaseAndCollectionList, + collectionSelector, + 'tree' + ); + const collectionElement = await browser.$(collectionSelector); + await collectionElement.waitForDisplayed(); +} + +async function renameCollectionSuccessFlow( + browser: CompassBrowser, + newName: string +) { + const page = new RenameCollectionModal(browser); + // wait for the collection modal to appear + await page.isVisible(); + + // enter the new name + await page.enterNewCollectionName(newName); + + // submit the form and confirm submission + await page.submitButton.click(); + + await page.confirmationScreen.waitForDisplayed(); + await page.submitButton.click(); + + // wait for success + await page.successToast.waitForDisplayed(); +} + +describe('Collection Rename Modal', () => { + let compass: Compass; + let browser: CompassBrowser; + + before(async function () { + compass = await beforeTests(); + browser = compass.browser; + + await setFeature(browser, 'enableRenameCollectionModal', true); + }); + + beforeEach(async function () { + await dropDatabase(databaseName); + + await createBlankCollection(databaseName, initialName); + await createBlankCollection(databaseName, 'bar'); + + await browser.connectWithConnectionString(); + }); + + after(async function () { + await afterTests(compass, this.currentTest); + }); + + afterEach(async function () { + await dropDatabase(databaseName); + await afterTest(compass, this.currentTest); + }); + + describe('from the sidebar', () => { + it('a collection can be renamed', async () => { + await navigateToCollectionInSidebar(browser); + + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + + // go through + await renameCollectionSuccessFlow(browser, newName); + + // confirm that the new collection name is shown in the sidebar + await browser + .$(Selectors.sidebarCollection(databaseName, newName)) + .waitForDisplayed(); + }); + + it('collection rename shows up on collection view', async () => { + await navigateToCollectionInSidebar(browser); + // open a collection tab + const collectionSelector = Selectors.sidebarCollection( + databaseName, + initialName + ); + const collectionElement = await browser.$(collectionSelector); + await collectionElement.waitForDisplayed(); + await collectionElement.click(); + + // wait until the collection tab has loaded + await browser.$(Selectors.CollectionHeaderNamespace).waitForDisplayed(); + + // open the rename collection flow from the sidebar + await browser.hover(collectionSelector); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + await renameCollectionSuccessFlow(browser, newName); + + await browser.$(Selectors.CollectionHeaderNamespace).waitForDisplayed(); + await browser.waitUntil(async () => { + const collectionHeaderContent = await browser + .$(Selectors.CollectionHeaderNamespace) + .getText(); + return ( + collectionHeaderContent.includes(newName) && + !collectionHeaderContent.includes(initialName) + ); + }); + }); + + it('collection rename can be retried after an error renaming the collection', async () => { + await navigateToCollectionInSidebar(browser); + + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + + // wait for the collection modal to appear + const modal = new RenameCollectionModal(browser); + await modal.isVisible(); + + // enter the new name - 'bar' already exists + await modal.enterNewCollectionName('bar'); + + // submit the form and confirm submission + await modal.submitButton.click(); + await modal.confirmationScreen.waitForDisplayed(); + await modal.submitButton.click(); + + // wait for error banner to appear + await modal.errorBanner.waitForDisplayed(); + + // try again, expecting success + await modal.enterNewCollectionName(newName); + await modal.submitButton.click(); + await modal.confirmationScreen.waitForDisplayed(); + await modal.submitButton.click(); + + // wait for success + await modal.successToast.waitForDisplayed(); + }); + }); + + describe('modal dismiss', () => { + it('the modal can be dismissed', async () => { + await navigateToCollectionInSidebar(browser); + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + // wait for the collection modal to appear + const modal = new RenameCollectionModal(browser); + await modal.isVisible(); + + await browser.clickVisible(modal.dismissButton); + await modal.isNotVisible(); + }); + + it('clears modal state when dismissed', async () => { + await navigateToCollectionInSidebar(browser); + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + + // wait for the collection modal to appear + const modal = new RenameCollectionModal(browser); + await modal.isVisible(); + + await modal.enterNewCollectionName('new-name'); + + await browser.clickVisible(modal.dismissButton); + await modal.isNotVisible(); + + // re-open the modal + // open the drop collection modal from the sidebar + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + + // assert that the form state has reset + expect(await modal.collectionNameInput.getValue()).to.equal(initialName); + }); + }); + + describe('saved aggregations', () => { + beforeEach( + 'navigate to aggregations tab and save pipeline on test collection', + async () => { + // Some tests navigate away from the numbers collection aggregations tab + await browser.navigateToCollectionTab( + 'rename-collection', + 'numbers', + 'Aggregations' + ); + // Get us back to the empty stage every time. Also test the Create New + // Pipeline flow while at it. + await browser.clickVisible(Selectors.CreateNewPipelineButton); + + await browser.clickVisible(Selectors.AddStageButton); + await browser.$(Selectors.stageEditor(0)).waitForDisplayed(); + // sanity check to make sure there's only one stage + const stageContainers = await browser.$$(Selectors.StageCard); + expect(stageContainers).to.have.lengthOf(1); + + await saveAggregationPipeline(browser, 'my-aggregation', [ + { $match: `{ name: 'john' }` }, + ]); + } + ); + + // functionality not implemented and tests failing + it.skip('preserves a saved aggregation for a namespace when a collection is renamed', async () => { + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + await renameCollectionSuccessFlow(browser, newName); + + // confirm the saved aggregation is still present for the newly renamed namespace + await browser.navigateToCollectionTab( + 'rename-collection', + newName, + 'Aggregations' + ); + + await browser.waitForAnimations( + Selectors.AggregationOpenSavedPipelinesButton + ); + await browser.clickVisible(Selectors.AggregationOpenSavedPipelinesButton); + await browser.waitForAnimations( + Selectors.AggregationSavedPipelinesPopover + ); + await browser + .$(Selectors.AggregationSavedPipelineCard('my-aggregation')) + .waitForDisplayed(); + }); + }); + + describe('saved queries', () => { + beforeEach('navigate to documents tab and save a query', async () => { + // set guide cue to not show up + await browser.execute((key) => { + localStorage.setItem(key, 'true'); + }, 'has_seen_stage_wizard_guide_cue'); + + const favoriteQueryName = 'list of numbers greater than 10 - query'; + + // Run a query + await browser.navigateToCollectionTab( + 'rename-collection', + 'numbers', + 'Documents' + ); + + await browser.runFindOperation('Documents', `{i: {$gt: 10}}`, { + limit: '10', + }); + await browser.clickVisible(Selectors.QueryBarHistoryButton); + + // Wait for the popover to show + const history = await browser.$(Selectors.QueryBarHistory); + await history.waitForDisplayed(); + + // wait for the recent item to show. + const recentCard = await browser.$(Selectors.QueryHistoryRecentItem); + await recentCard.waitForDisplayed(); + + // Save the ran query + await browser.hover(Selectors.QueryHistoryRecentItem); + await browser.clickVisible(Selectors.QueryHistoryFavoriteAnItemButton); + const favoriteQueryNameField = await browser.$( + Selectors.QueryHistoryFavoriteItemNameField + ); + await favoriteQueryNameField.setValue(favoriteQueryName); + await browser.clickVisible(Selectors.QueryHistorySaveFavoriteItemButton); + }); + + // functionality not implemented and tests failing + it.skip('preserves a saved query for a namespace when a collection is renamed', async () => { + // open the rename collection modal + await browser.hover( + Selectors.sidebarCollection(databaseName, initialName) + ); + await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.RenameCollectionButton); + await renameCollectionSuccessFlow(browser, newName); + await browser.navigateToCollectionTab( + 'rename-collection', + newName, + 'Documents' + ); + + await browser.clickVisible(Selectors.QueryBarHistoryButton); + + // Wait for the popover to show + const history = await browser.$(Selectors.QueryBarHistory); + await history.waitForDisplayed(); + + const button = await browser.$(Selectors.QueryHistoryFavoritesButton); + await browser.debug(); + await button.clickVisible(); + + await browser.$(Selectors.QueryHistoryFavoriteItem).waitForDisplayed(); + + await setTimeout(3000); + }); + }); +}); diff --git a/packages/compass-home/src/components/home.tsx b/packages/compass-home/src/components/home.tsx index 8d8433c6bb1..17364b59acb 100644 --- a/packages/compass-home/src/components/home.tsx +++ b/packages/compass-home/src/components/home.tsx @@ -45,6 +45,7 @@ import { CompassFindInPagePlugin } from '@mongodb-js/compass-find-in-page'; import { CreateNamespacePlugin, DropNamespacePlugin, + RenameCollectionPlugin, } from '@mongodb-js/compass-databases-collections'; import { ImportPlugin, ExportPlugin } from '@mongodb-js/compass-import-export'; import { DataServiceProvider } from 'mongodb-data-service/provider'; @@ -361,6 +362,7 @@ function Home({ + { case 'drop-database': emit('open-drop-database', ns.database); return; + case 'rename-collection': + emit('open-rename-collection', ns); + return; case 'drop-collection': emit('open-drop-collection', ns); return; diff --git a/packages/data-service/src/data-service.spec.ts b/packages/data-service/src/data-service.spec.ts index f4ceb199633..2a6a5cb2d89 100644 --- a/packages/data-service/src/data-service.spec.ts +++ b/packages/data-service/src/data-service.spec.ts @@ -3,7 +3,7 @@ import { ObjectId } from 'bson'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import type { Sort } from 'mongodb'; -import { MongoServerError } from 'mongodb'; +import { Collection, MongoServerError } from 'mongodb'; import { MongoClient } from 'mongodb'; import sinon from 'sinon'; import { v4 as uuid } from 'uuid'; @@ -544,6 +544,43 @@ describe('DataService', function () { }); }); + describe('#renameCollection', function () { + beforeEach(async function () { + for (const collectionName of [ + 'initialCollection', + 'renamedCollection', + ]) { + await dataService + .dropCollection(`${testDatabaseName}.${collectionName}`) + .catch(() => null); + } + await dataService.createCollection( + `${testDatabaseName}.initialCollection`, + {} + ); + }); + it('renames the collection', async function () { + await dataService.renameCollection( + `${testDatabaseName}.initialCollection`, + 'renamedCollection' + ); + + const [collection] = await dataService.listCollections( + testDatabaseName, + { name: 'renamedCollection' } + ); + expect(collection).to.exist; + }); + + it('returns the collection object', async function () { + const result = await dataService.renameCollection( + `${testDatabaseName}.initialCollection`, + 'renamedCollection' + ); + expect(result).to.be.instanceOf(Collection); + }); + }); + describe('#estimatedCount', function () { it('returns a 0 for an empty collection', async function () { const count = await dataService.estimatedCount( diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index 8cde083fd7c..f211218737e 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -350,6 +350,14 @@ export interface DataService { */ dropCollection(ns: string): Promise; + /** + * + */ + renameCollection( + ns: string, + newCollectionName: string + ): Promise>; + /** * Count the number of documents in the collection. * @@ -1570,6 +1578,15 @@ class DataServiceImpl extends WithLogContext implements DataService { return await coll.drop(options); } + @op(mongoLogId(1_001_000_276)) + renameCollection( + ns: string, + newCollectionName: string + ): Promise> { + const db = this._database(ns, 'META'); + return db.renameCollection(this._collectionName(ns), newCollectionName); + } + @op(mongoLogId(1_001_000_040), ([db], result) => { return { db, ...(result && { result }) }; }) diff --git a/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.spec.tsx b/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.spec.tsx new file mode 100644 index 00000000000..28bbf53058d --- /dev/null +++ b/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.spec.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Sinon from 'sinon'; +import { expect } from 'chai'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; + +import { RenameCollectionPlugin } from '../..'; +import AppRegistry from 'hadron-app-registry'; + +describe('CreateCollectionModal [Component]', function () { + const sandbox = Sinon.createSandbox(); + const appRegistry = sandbox.spy(new AppRegistry()); + const dataService = { + renameCollection: sandbox.stub().resolves({}), + }; + context('when the modal is visible', function () { + beforeEach(function () { + const Plugin = RenameCollectionPlugin.withMockServices({ + globalAppRegistry: appRegistry, + dataService, + }); + render( ); + appRegistry.emit('open-rename-collection', { + database: 'foo', + collection: 'bar', + }); + }); + + afterEach(function () { + sandbox.resetHistory(); + cleanup(); + }); + + it('renders the correct title', () => { + expect(screen.getByText('Rename collection')).to.exist; + }); + + it('renders the correct text on the submit button', () => { + const submitButton = screen.getByTestId('submit-button'); + expect(submitButton.textContent).to.equal('Proceed to Rename'); + }); + + it('opens with the collection name in the input', () => { + const input: HTMLInputElement = screen.getByTestId( + 'rename-collection-name-input' + ); + expect(input.value).to.equal('bar'); + }); + + it('disables the submit button when the value is equal to the initial collection name', () => { + const submitButton = screen.getByTestId('submit-button'); + const input = screen.getByTestId('rename-collection-name-input'); + expect(submitButton).to.have.attribute('disabled'); + + fireEvent.change(input, { target: { value: 'baz' } }); + expect(submitButton).not.to.have.attribute('disabled'); + fireEvent.change(input, { target: { value: 'bar' } }); + expect(submitButton).to.have.attribute('disabled'); + }); + + context('when the user has submitted the form', () => { + beforeEach(() => { + const submitButton = screen.getByTestId('submit-button'); + const input = screen.getByTestId('rename-collection-name-input'); + fireEvent.change(input, { target: { value: 'baz' } }); + fireEvent.click(submitButton); + + expect(screen.getByTestId('rename-collection-modal')).to.exist; + }); + + it('renders the rename collection confirmation screen', () => { + expect(screen.getByText('Confirm rename collection')).to.exist; + }); + + it('renders the confirmation warning', () => { + expect( + screen.getByText('Are you sure you want to rename "bar" to "baz"?') + ).to.exist; + }); + + it('renders the correct text on the submit button', () => { + const submitButton = screen.getByTestId('submit-button'); + expect(submitButton.textContent).to.equal('Yes, rename collection'); + }); + }); + }); +}); diff --git a/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.tsx b/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.tsx new file mode 100644 index 00000000000..8734da9e1c6 --- /dev/null +++ b/packages/databases-collections/src/components/rename-collection-modal/rename-collection-modal.tsx @@ -0,0 +1,153 @@ +import { + Banner, + Body, + FormFieldContainer, + FormModal, + SpinLoader, + TextInput, + css, + spacing, +} from '@mongodb-js/compass-components'; +import React, { useCallback, useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import type { RenameCollectionRootState } from '../../modules/rename-collection/rename-collection'; +import { + renameCollection, + hideModal, +} from '../../modules/rename-collection/rename-collection'; +import { useTrackOnChange } from '@mongodb-js/compass-logging/provider'; + +export interface RenameCollectionModalProps { + isVisible: boolean; + error: Error | null; + initialCollectionName: string; + isRunning: boolean; + hideModal: () => void; + renameCollection: (newCollectionName: string) => void; +} + +const progressContainerStyles = css({ + display: 'flex', + gap: spacing[2], + alignItems: 'center', +}); + +type ModalState = 'input-form' | 'confirmation-screen'; + +function RenameCollectionModal({ + isVisible, + error, + initialCollectionName, + isRunning, + hideModal, + renameCollection, +}: RenameCollectionModalProps) { + const [newName, setNewName] = useState(initialCollectionName); + const [modalState, setModalState] = useState(); + useEffect(() => { + if (isVisible) { + setNewName(initialCollectionName); + setModalState('input-form'); + } + }, [isVisible, initialCollectionName]); + const onNameConfirmationChange = useCallback( + (evt: React.ChangeEvent) => { + setNewName(evt?.target.value); + }, + [setNewName] + ); + const onFormSubmit = () => { + if (modalState === 'confirmation-screen') { + setModalState('input-form'); + renameCollection(newName); + } else { + setModalState('confirmation-screen'); + } + }; + + useTrackOnChange( + 'COMPASS-DATABASES-COLLECTIONS-UI', + (track) => { + if (isVisible) { + track('Screen', { name: 'rename_collection_modal' }); + } + }, + [isVisible], + undefined + ); + + const onHide = useCallback(() => { + hideModal(); + }, [hideModal]); + + return ( + + {modalState === 'input-form' && ( + + + + )} + {modalState === 'confirmation-screen' && ( + +
+ {`Are you sure you want to rename "${initialCollectionName}" to "${newName}"?`} +
+
+ )} + {error && modalState === 'input-form' && ( + + {error.message} + + )} + {modalState === 'confirmation-screen' && ( + + Renaming collection will result in loss of any unsaved queries, + filters or aggregation pipeline + + )} + {isRunning && ( + + + Renaming Collection… + + )} +
+ ); +} + +const MappedRenameCollectionModal = connect( + ( + state: RenameCollectionRootState + ): Omit => + state, + { + hideModal, + renameCollection, + } +)(RenameCollectionModal); + +export default MappedRenameCollectionModal; diff --git a/packages/databases-collections/src/index.ts b/packages/databases-collections/src/index.ts index c562721431d..494e6442f0a 100644 --- a/packages/databases-collections/src/index.ts +++ b/packages/databases-collections/src/index.ts @@ -15,6 +15,8 @@ import { import CreateNamespaceModal from './components/create-namespace-modal'; import { activatePlugin as activateCreateNamespacePlugin } from './stores/create-namespace'; import { DatabasesPlugin } from './databases-plugin'; +import MappedRenameCollectionModal from './components/rename-collection-modal/rename-collection-modal'; +import { activateRenameCollectionPlugin } from './stores/rename-collection'; // View collections list plugin. const COLLECTIONS_PLUGIN_ROLE = { @@ -57,6 +59,18 @@ export const DropNamespacePlugin = registerHadronPlugin( } ); +export const RenameCollectionPlugin = registerHadronPlugin( + { + name: 'RenameCollectionPlugin', + component: MappedRenameCollectionModal, + activate: activateRenameCollectionPlugin, + }, + { + dataService: + dataServiceLocator as typeof dataServiceLocator<'renameCollection'>, + } +); + /** * Activate all the components in the package. **/ diff --git a/packages/databases-collections/src/modules/rename-collection/rename-collection.spec.ts b/packages/databases-collections/src/modules/rename-collection/rename-collection.spec.ts new file mode 100644 index 00000000000..830f214649c --- /dev/null +++ b/packages/databases-collections/src/modules/rename-collection/rename-collection.spec.ts @@ -0,0 +1,99 @@ +import { expect } from 'chai'; +import Sinon from 'sinon'; +import type { RenameCollectionRootState } from './rename-collection'; +import { renameCollection, renameRequestInProgress } from './rename-collection'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { AnyAction } from 'redux'; +import AppRegistry from 'hadron-app-registry'; +import type { RenameCollectionPluginServices } from '../../stores/rename-collection'; +import { activateRenameCollectionPlugin } from '../../stores/rename-collection'; + +describe('rename collection module', function () { + let store: ReturnType['store']; + + const sandbox = Sinon.createSandbox(); + const appRegistry = sandbox.spy(new AppRegistry()); + const dataService = { + renameCollection: sandbox.stub().resolves({}), + }; + + const extraThunkArgs: RenameCollectionPluginServices = { + globalAppRegistry: appRegistry, + dataService, + }; + + context('when the modal is visible', function () { + beforeEach(function () { + const plugin = activateRenameCollectionPlugin( + {}, + { + globalAppRegistry: appRegistry, + dataService, + } + ); + store = plugin.store; + }); + + describe('#reducer', function () { + context('RENAME_REQUEST_IN_PROGRESS', () => { + it('marks the state as running', () => { + store.dispatch(renameRequestInProgress()); + expect(store.getState().isRunning).to.be.true; + }); + it('nulls out any existing errors', () => { + store.dispatch(renameRequestInProgress()); + expect(store.getState().error).to.be.null; + }); + }); + + context('OPEN', () => { + it('marks the state as running', () => { + store.dispatch(renameRequestInProgress()); + expect(store.getState().isRunning).to.be.true; + }); + it('nulls out any existing errors', () => { + store.dispatch(renameRequestInProgress()); + expect(store.getState().error).to.be.null; + }); + }); + }); + + describe('#renameCollection', () => { + let dispatch: ThunkDispatch< + RenameCollectionRootState, + RenameCollectionPluginServices, + AnyAction + >; + let getState: () => RenameCollectionRootState; + beforeEach(() => { + dispatch = store.dispatch.bind(store); + getState = store.getState.bind(store); + }); + + it('renames the collection', async () => { + const creator = renameCollection('new-collection'); + await creator(dispatch, getState, extraThunkArgs); + expect(dataService.renameCollection).to.have.been.called; + }); + + context('when there is an error', () => { + const error = new Error('something went wrong'); + beforeEach(() => { + dataService.renameCollection.rejects(error); + }); + + it('sets the state to not running', async () => { + const creator = renameCollection('new-collection'); + await creator(dispatch, getState, extraThunkArgs); + expect(store.getState().isRunning).to.be.false; + }); + + it('reports an error', async () => { + const creator = renameCollection('new-collection'); + await creator(dispatch, getState, extraThunkArgs); + expect(store.getState().error).to.equal(error); + }); + }); + }); + }); +}); diff --git a/packages/databases-collections/src/modules/rename-collection/rename-collection.ts b/packages/databases-collections/src/modules/rename-collection/rename-collection.ts new file mode 100644 index 00000000000..b5766c40fb7 --- /dev/null +++ b/packages/databases-collections/src/modules/rename-collection/rename-collection.ts @@ -0,0 +1,133 @@ +import type { AnyAction } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; + +import type { Reducer } from 'redux'; +import type { RenameCollectionPluginServices } from '../../stores/rename-collection'; +import { openToast } from '@mongodb-js/compass-components'; + +/** + * Open action name. + */ +const OPEN = 'databases-collections/rename-collection/OPEN'; +const CLOSE = 'database-collections/rename-collection/CLOSE'; +const RENAME_REQUEST_IN_PROGRESS = + 'database-collections/rename-collection/TOGGLE_IS_RUNNING'; +const HANDLE_ERROR = 'database-collections/rename-collection/HANDLE_ERROR'; + +/** + * Open drop database action creator. + */ +export const open = (db: string, collection: string) => ({ + type: OPEN, + db, + collection, +}); + +export const close = () => ({ + type: CLOSE, +}); + +export const renameRequestInProgress = () => ({ + type: RENAME_REQUEST_IN_PROGRESS, +}); + +const handleError = (error: Error) => ({ + type: HANDLE_ERROR, + error, +}); + +export type RenameCollectionRootState = { + error: Error | null; + initialCollectionName: string; + isRunning: boolean; + isVisible: boolean; + databaseName: string; +}; + +const defaultState: RenameCollectionRootState = { + isRunning: false, + isVisible: false, + error: null, + databaseName: '', + initialCollectionName: '', +}; + +const reducer: Reducer = ( + state: RenameCollectionRootState = defaultState, + action: AnyAction +): RenameCollectionRootState => { + if (action.type === CLOSE) { + return defaultState; + } else if (action.type === OPEN) { + return { + initialCollectionName: action.collection, + databaseName: action.db, + isVisible: true, + isRunning: false, + error: null, + }; + } else if (action.type === RENAME_REQUEST_IN_PROGRESS) { + return { + ...state, + isRunning: true, + error: null, + }; + } else if (action.type === HANDLE_ERROR) { + return { + ...state, + error: action.error, + isRunning: false, + }; + } + return state; +}; + +export default reducer; + +export const hideModal = (): ThunkAction< + void, + RenameCollectionRootState, + void, + AnyAction +> => { + return (dispatch) => { + dispatch(close()); + }; +}; + +/** + * A thunk action that renames a collection. + * */ +export const renameCollection = ( + newCollectionName: string +): ThunkAction< + Promise, + RenameCollectionRootState, + RenameCollectionPluginServices, + AnyAction +> => { + return async (dispatch, getState, { dataService, globalAppRegistry }) => { + const state = getState(); + const { databaseName, initialCollectionName } = state; + + dispatch(renameRequestInProgress()); + const oldNamespace = `${databaseName}.${initialCollectionName}`; + const newNamespace = `${databaseName}.${newCollectionName}`; + + try { + await dataService.renameCollection(oldNamespace, newCollectionName); + globalAppRegistry.emit('collection-renamed', { + to: newNamespace, + from: oldNamespace, + }); + dispatch(close()); + openToast('collection-rename-success', { + variant: 'success', + title: `Collection renamed to ${newCollectionName}`, + timeout: 5_000, + }); + } catch (e) { + dispatch(handleError(e as Error)); + } + }; +}; diff --git a/packages/databases-collections/src/stores/rename-collection.spec.tsx b/packages/databases-collections/src/stores/rename-collection.spec.tsx new file mode 100644 index 00000000000..ac35a234004 --- /dev/null +++ b/packages/databases-collections/src/stores/rename-collection.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import Sinon from 'sinon'; + +import { expect } from 'chai'; +import AppRegistry from 'hadron-app-registry'; +import { RenameCollectionPlugin } from '..'; +import { render, cleanup, screen } from '@testing-library/react'; + +describe('RenameCollectionPlugin', function () { + const sandbox = Sinon.createSandbox(); + const appRegistry = sandbox.spy(new AppRegistry()); + const dataService = { + renameCollection: sandbox.stub().resolves({}), + }; + beforeEach(function () { + const Plugin = RenameCollectionPlugin.withMockServices({ + globalAppRegistry: appRegistry, + dataService, + }); + + render( ); + }); + + afterEach(function () { + sandbox.resetHistory(); + cleanup(); + }); + + it('handles the open-rename-collection event', function () { + appRegistry.emit('open-rename-collection', { + database: 'foo', + collection: 'bar', + }); + + expect(screen.getByRole('heading', { name: 'Rename collection' })).to.exist; + }); +}); diff --git a/packages/databases-collections/src/stores/rename-collection.ts b/packages/databases-collections/src/stores/rename-collection.ts new file mode 100644 index 00000000000..a7dcf7b994a --- /dev/null +++ b/packages/databases-collections/src/stores/rename-collection.ts @@ -0,0 +1,40 @@ +import { legacy_createStore, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; +import type AppRegistry from 'hadron-app-registry'; +import type { DataService } from 'mongodb-data-service'; +import reducer, { open } from '../modules/rename-collection/rename-collection'; + +export type RenameCollectionPluginServices = { + dataService: Pick; + globalAppRegistry: AppRegistry; +}; + +export function activateRenameCollectionPlugin( + _: unknown, + { globalAppRegistry, dataService }: RenameCollectionPluginServices +) { + const store = legacy_createStore( + reducer, + applyMiddleware( + thunk.withExtraArgument({ + globalAppRegistry, + dataService, + }) + ) + ); + + const onRenameCollection = (ns: { database: string; collection: string }) => { + store.dispatch(open(ns.database, ns.collection)); + }; + globalAppRegistry.on('open-rename-collection', onRenameCollection); + + return { + store, + deactivate() { + globalAppRegistry.removeListener( + 'open-rename-collection', + onRenameCollection + ); + }, + }; +} diff --git a/packages/my-queries-storage/src/pipeline-storage.ts b/packages/my-queries-storage/src/pipeline-storage.ts index 07eb3c75ce1..ccc58dd843e 100644 --- a/packages/my-queries-storage/src/pipeline-storage.ts +++ b/packages/my-queries-storage/src/pipeline-storage.ts @@ -101,6 +101,13 @@ export class PipelineStorage { } } + /** loads all pipelines that satisfy `predicate` */ + loadMany( + predicate: (arg0: SavedPipeline) => boolean + ): Promise { + return this.loadAll().then((pipelines) => pipelines.filter(predicate)); + } + private async loadOne(id: string): Promise { const [item, stats] = await this.userData.readOneWithStats(id); return this.mergeStats(item, stats); @@ -125,7 +132,10 @@ export class PipelineStorage { return await this.loadOne(data.id); } - async updateAttributes(id: string, attributes: Partial) { + async updateAttributes( + id: string, + attributes: Partial + ): Promise { await this.userData.write(id, { ...(await this.loadOne(id)), ...attributes,