diff --git a/packages/compass-assistant/src/@ai-sdk/react/use-chat.ts b/packages/compass-assistant/src/@ai-sdk/react/use-chat.ts index 486ac1064f0..2e630cb46f3 100644 --- a/packages/compass-assistant/src/@ai-sdk/react/use-chat.ts +++ b/packages/compass-assistant/src/@ai-sdk/react/use-chat.ts @@ -46,20 +46,21 @@ export type UseChatHelpers = { | 'clearError' >; -export type UseChatOptions = - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - ({ chat: Chat } | ChatInit) & { - /** +export type UseChatOptions = ( + | { chat: Chat } + | ChatInit +) & { + /** Custom throttle wait in ms for the chat messages and data updates. Default is undefined, which disables throttling. */ - experimental_throttle?: number; + experimental_throttle?: number; - /** - * Whether to resume an ongoing chat generation stream. - */ - resume?: boolean; - }; + /** + * Whether to resume an ongoing chat generation stream. + */ + resume?: boolean; +}; export function useChat({ experimental_throttle: throttleWaitMs, diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index b0c50cbfdca..7a01978f346 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -53,19 +53,25 @@ import { TableBody, flexRender, useLeafyGreenTable, + useLeafyGreenVirtualTable, + type LeafyGreenVirtualItem, getExpandedRowModel, getFilteredRowModel, + type TableProps, } from '@leafygreen-ui/table'; import type { Row as LgTableRowType } from '@tanstack/table-core'; // TODO(COMPASS-8437): import from LG export type { LGColumnDef, HeaderGroup, + LeafyGreenVirtualTable, + LeafyGreenTable, LeafyGreenTableCell, LeafyGreenTableRow, LGTableDataType, LGRowData, SortingState, + CellContext, } from '@leafygreen-ui/table'; import { Tabs, Tab } from '@leafygreen-ui/tabs'; import TextArea from '@leafygreen-ui/text-area'; @@ -200,6 +206,9 @@ export { InfoSprinkle, flexRender, useLeafyGreenTable, + useLeafyGreenVirtualTable, + type LeafyGreenVirtualItem, + type TableProps, getExpandedRowModel, getFilteredRowModel, type LgTableRowType, diff --git a/packages/compass-e2e-tests/helpers/commands/database-workspaces.ts b/packages/compass-e2e-tests/helpers/commands/database-workspaces.ts index c84321c1916..3c19cd77b4b 100644 --- a/packages/compass-e2e-tests/helpers/commands/database-workspaces.ts +++ b/packages/compass-e2e-tests/helpers/commands/database-workspaces.ts @@ -8,7 +8,7 @@ export async function navigateToDatabaseCollectionsTab( dbName: string ): Promise { await browser.navigateToConnectionTab(connectionName, 'Databases'); - await browser.clickVisible(Selectors.databaseCardClickable(dbName)); + await browser.clickVisible(`${Selectors.databaseRow(dbName)} td:first-child`); await waitUntilActiveDatabaseTab(browser, connectionName, dbName); } diff --git a/packages/compass-e2e-tests/helpers/commands/scroll-to-virtual-item.ts b/packages/compass-e2e-tests/helpers/commands/scroll-to-virtual-item.ts index 2d0f1c746da..8a1049f2286 100644 --- a/packages/compass-e2e-tests/helpers/commands/scroll-to-virtual-item.ts +++ b/packages/compass-e2e-tests/helpers/commands/scroll-to-virtual-item.ts @@ -49,8 +49,33 @@ export async function scrollToVirtualItem( browser: CompassBrowser, containerSelector: string, targetSelector: string, - role: 'grid' | 'tree' + role: 'grid' | 'tree' | 'table' ): Promise { + if (role === 'table') { + // we disable virtual scrolling for tables for now + const expectedRowCount = parseInt( + await browser + .$(`${containerSelector} table`) + .getAttribute('aria-rowcount'), + 10 + ); + const rowCount = await browser.$$('tbody tr').length; + + if (rowCount !== expectedRowCount) { + throw new Error( + `${rowCount} rows found, but expected ${expectedRowCount}. Is virtual rendering of the table disabled as expected?` + ); + } + + const targetElement = browser.$(targetSelector); + await targetElement.waitForExist(); + // align the bottom of the element to the bottom of the view so it doesn't + // sit under the sticky header + await targetElement.scrollIntoView(false); + await targetElement.waitForDisplayed(); + return true; + } + const config = role === 'tree' ? treeConfig : gridConfig; let found = false; diff --git a/packages/compass-e2e-tests/helpers/compass.ts b/packages/compass-e2e-tests/helpers/compass.ts index 508810a0f8f..103598c880b 100644 --- a/packages/compass-e2e-tests/helpers/compass.ts +++ b/packages/compass-e2e-tests/helpers/compass.ts @@ -660,6 +660,10 @@ async function startCompassElectron( // Making sure end-of-life connection modal is not shown, simplify any test connecting to such a server process.env.COMPASS_DISABLE_END_OF_LIFE_CONNECTION_MODAL = 'true'; + // TODO(COMPASS-9977) Turn off virtual scrolling in e2e tests until we can fix + // browser.scrollToVirtualItem() to work with it + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + const options = { automationProtocol: 'webdriver' as const, capabilities: { diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 54ec3a6db90..6eddaac9c92 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -503,56 +503,47 @@ export const ShellInput = '[data-testid="shell-input"]'; export const ShellOutput = '[data-testid="shell-output"]'; // Instance screen -export const DatabasesTable = '[data-testid="database-grid"]'; +export const DatabasesTable = '[data-testid="databases-list"]'; export const InstanceCreateDatabaseButton = '[data-testid="create-controls"] button'; export const InstanceRefreshDatabaseButton = '[data-testid="refresh-controls"] button'; -export const DatabaseCard = '[data-testid="database-grid-item"]'; -// assume that there's only one hovered card at a time and that the first and only button is the drop button -export const DatabaseCardDrop = - '[data-testid="database-grid"] [data-testid="namespace-card-actions"] button'; -export const ServerStats = '.serverstats'; -export const DatabaseStatLoader = `${DatabaseCard} [data-testid="namespace-param-fallback"][data-ready=false]`; +export const DatabaseStatLoader = + '[data-testid="databases-list"] [data-testid="placeholder"]'; -export const databaseCard = (dbName: string): string => { - return `${DatabaseCard}[data-id="${dbName}"]`; +export const databaseRow = (dbName: string): string => { + return `[data-testid="databases-list-row-${dbName}"]`; }; -export const databaseCardClickable = (dbName: string): string => { - // webdriver does not like clicking on the card even though the card has the - // click handler, so click on the title - return `${databaseCard(dbName)} [title="${dbName}"]`; +export const databaseRowDrop = (dbName: string): string => { + return `${databaseRow(dbName)} button[data-action="delete"]`; }; +// Performance screen +export const ServerStats = '.serverstats'; + // Database screen -export const CollectionsGrid = '[data-testid="collection-grid"]'; +export const CollectionsTable = '[data-testid="collections-list"]'; export const DatabaseCreateCollectionButton = '[data-testid="create-controls"] button'; export const DatabaseRefreshCollectionButton = '[data-testid="refresh-controls"] button'; -export const CollectionCard = '[data-testid="collection-grid-item"]'; -// assume that there's only one hovered card at a time and that the first and only button is the drop button -export const CollectionCardDrop = - '[data-testid="collection-grid"] [data-testid="namespace-card-actions"] button'; -export const collectionCard = ( +export const collectionRow = ( dbName: string, collectionName: string ): string => { - return `${CollectionCard}[data-id="${dbName}.${collectionName}"]`; + return `[data-testid="collections-list-row-${collectionName}"]`; }; -export const collectionCardClickable = ( +export const collectionRowDrop = ( dbName: string, collectionName: string ): string => { - // webdriver does not like clicking on the card even though the card has the - // click handler, so click on the title - return `${collectionCard( + return `${collectionRow( dbName, collectionName - )} [title="${collectionName}"]`; + )} button[data-action="delete"]`; }; // Collection screen diff --git a/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts index d710b4c0c94..50d6fdd520a 100644 --- a/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts +++ b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts @@ -92,6 +92,11 @@ export async function mochaGlobalSetup(this: Mocha.Runner) { if (isTestingWeb(context) && !isTestingAtlasCloudExternal(context)) { debug('Starting Compass Web server ...'); + + // TODO(COMPASS-9977) Turn off virtual scrolling in e2e tests until we can fix + // browser.scrollToVirtualItem() to work with it + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + if (isTestingAtlasCloudSandbox(context)) { const compassWeb = await spawnCompassWebSandboxAndSignInToAtlas( { diff --git a/packages/compass-e2e-tests/index.ts b/packages/compass-e2e-tests/index.ts index 86a31aec1a1..e961e7940f8 100644 --- a/packages/compass-e2e-tests/index.ts +++ b/packages/compass-e2e-tests/index.ts @@ -26,7 +26,16 @@ const FIRST_TEST = 'tests/time-to-first-query.test.ts'; async function cleanupOnInterrupt() { // First trigger an abort on the mocha runner abortRunner?.(); - await runnerPromise; + // Don't wait when bailing because it can take minutes of retries before it + // finally times out, the process exits back to the terminal but some zombie + // child stays around and keeps logging.. We only use bail locally when + // working on tests manually and in that case we probably don't care about the + // cleanup. If you see a test you're working on waiting for something that's + // never going to happen then you probably want to kill it and get back + // control immediately. + if (!context.mochaBail) { + await runnerPromise; + } } function terminateOnTimeout() { diff --git a/packages/compass-e2e-tests/package.json b/packages/compass-e2e-tests/package.json index b4404ab9a2d..112af3e9e2a 100644 --- a/packages/compass-e2e-tests/package.json +++ b/packages/compass-e2e-tests/package.json @@ -23,10 +23,10 @@ "stop-server-2": "mongodb-runner stop --id=e2e-2", "start-servers": "npm run start-server-1 && npm run start-server-2", "stop-servers": "npm run stop-server-1 && npm run stop-server-2", - "test-noserver": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test -- --disable-start-stop --bail", - "test-noserver-nocompile": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test -- --no-native-modules --no-compile --disable-start-stop --bail", + "test-noserver": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test -- --disable-start-stop --mocha-bail", + "test-noserver-nocompile": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test -- --no-native-modules --no-compile --disable-start-stop --mocha-bail", "test-web": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test web", - "test-web-noserver": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test web -- --disable-start-stop --bail", + "test-web-noserver": "env DEBUG=hadron*,mongo*,compass*,xvfb-maybe* npm run test web -- --disable-start-stop --mocha-bail", "coverage-merge": "nyc merge .log/coverage .nyc_output/coverage.json", "coverage-report": "npm run coverage-merge && nyc report" }, diff --git a/packages/compass-e2e-tests/tests/database-collections-tab.test.ts b/packages/compass-e2e-tests/tests/database-collections-tab.test.ts index d0d2436a56b..54038f1199b 100644 --- a/packages/compass-e2e-tests/tests/database-collections-tab.test.ts +++ b/packages/compass-e2e-tests/tests/database-collections-tab.test.ts @@ -22,23 +22,23 @@ async function waitForCollectionAndBadge( collectionName: string, badgeSelector: string ) { - const cardSelector = Selectors.collectionCard(dbName, collectionName); + const rowSelector = Selectors.collectionRow(dbName, collectionName); await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, - cardSelector, - 'grid' + Selectors.CollectionsTable, + rowSelector, + 'table' ); - // Hit refresh because depending on timing the card might appear without the + // Hit refresh because depending on timing the row might appear without the // badge at first. Especially in Firefox for whatever reason. await browser.clickVisible(Selectors.DatabaseRefreshCollectionButton); await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, - cardSelector, - 'grid' + Selectors.CollectionsTable, + rowSelector, + 'table' ); - await browser.$(cardSelector).$(badgeSelector).waitForDisplayed(); + await browser.$(rowSelector).$(badgeSelector).waitForDisplayed(); } describe('Database collections tab', function () { @@ -71,7 +71,7 @@ describe('Database collections tab', function () { }); it('contains a list of collections', async function () { - const collectionsGrid = browser.$(Selectors.CollectionsGrid); + const collectionsGrid = browser.$(Selectors.CollectionsTable); await collectionsGrid.waitForDisplayed(); for (const collectionName of [ @@ -80,29 +80,28 @@ describe('Database collections tab', function () { 'json-file', 'numbers', ]) { - const collectionSelector = Selectors.collectionCard( + const collectionSelector = Selectors.collectionRow( 'test', collectionName ); const found = await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, + Selectors.CollectionsTable, collectionSelector, - 'grid' + 'table' ); expect(found, collectionSelector).to.be.true; } }); - it('links collection cards to the collection documents tab', async function () { + it('links collection rows to the collection documents tab', async function () { await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, - Selectors.collectionCard('test', 'json-array'), - 'grid' + Selectors.CollectionsTable, + Selectors.collectionRow('test', 'json-array'), + 'table' ); await browser.clickVisible( - Selectors.collectionCardClickable('test', 'json-array'), - { scroll: true } + `${Selectors.collectionRow('test', 'json-array')} td:first-child` ); // lands on the collection screen with all its tabs @@ -137,22 +136,20 @@ describe('Database collections tab', function () { 'test' ); - const selector = Selectors.collectionCard('test', collectionName); + const selector = Selectors.collectionRow('test', collectionName); await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, + Selectors.CollectionsTable, selector, - 'grid' + 'table' ); - const collectionCard = browser.$(selector); - await collectionCard.waitForDisplayed(); - - await collectionCard.scrollIntoView(false); + const collectionRow = browser.$(selector); + await collectionRow.waitForDisplayed(); await browser.waitUntil(async () => { - // open the drop collection modal from the collection card - await browser.hover(`${selector} [title="${collectionName}"]`); - const el = browser.$(Selectors.CollectionCardDrop); + // open the drop collection modal from the collection row + await browser.hover(`${selector}`); + const el = browser.$(Selectors.collectionRowDrop('test', collectionName)); if (await el.isDisplayed()) { return true; } @@ -162,12 +159,14 @@ describe('Database collections tab', function () { return false; }); - await browser.clickVisible(Selectors.CollectionCardDrop); + await browser.clickVisible( + Selectors.collectionRowDrop('test', collectionName) + ); await browser.dropNamespace(collectionName); // wait for it to be gone - await collectionCard.waitForExist({ reverse: true }); + await collectionRow.waitForExist({ reverse: true }); // the app should still be on the database Collections tab because there are // other collections in this database @@ -345,13 +344,13 @@ describe('Database collections tab', function () { ); await browser.clickVisible(Selectors.DatabaseRefreshCollectionButton); - const collSelector = Selectors.collectionCard(db, coll); + const collSelector = Selectors.collectionRow(db, coll); await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, + Selectors.CollectionsTable, collSelector, - 'grid' + 'table' ); - const coll2Card = browser.$(collSelector); - await coll2Card.waitForDisplayed(); + const coll2Row = browser.$(collSelector); + await coll2Row.waitForDisplayed(); }); }); diff --git a/packages/compass-e2e-tests/tests/in-use-encryption.test.ts b/packages/compass-e2e-tests/tests/in-use-encryption.test.ts index 1e6a46a9f13..2201812c81c 100644 --- a/packages/compass-e2e-tests/tests/in-use-encryption.test.ts +++ b/packages/compass-e2e-tests/tests/in-use-encryption.test.ts @@ -503,15 +503,15 @@ describe('CSFLE / QE', function () { databaseName ); - const selector = Selectors.collectionCard(databaseName, collectionName); + const selector = Selectors.collectionRow(databaseName, collectionName); await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, + Selectors.CollectionsTable, selector, - 'grid' + 'table' ); - const collectionCard = browser.$(selector); - await collectionCard.waitForDisplayed(); + const collectionRow = browser.$(selector); + await collectionRow.waitForDisplayed(); const collectionListFLE2BadgeElement = browser.$( Selectors.CollectionListFLE2Badge diff --git a/packages/compass-e2e-tests/tests/instance-databases-tab.test.ts b/packages/compass-e2e-tests/tests/instance-databases-tab.test.ts index 15fab6d3e3b..6d05761a45c 100644 --- a/packages/compass-e2e-tests/tests/instance-databases-tab.test.ts +++ b/packages/compass-e2e-tests/tests/instance-databases-tab.test.ts @@ -50,42 +50,37 @@ describe('Instance databases tab', function () { const dbTable = browser.$(Selectors.DatabasesTable); await dbTable.waitForDisplayed(); - const dbSelectors = INITIAL_DATABASE_NAMES.map(Selectors.databaseCard); + const dbSelectors = INITIAL_DATABASE_NAMES.map(Selectors.databaseRow); for (const dbSelector of dbSelectors) { const found = await browser.scrollToVirtualItem( Selectors.DatabasesTable, dbSelector, - 'grid' + 'table' ); expect(found, dbSelector).to.be.true; } }); - it('links database cards to the database collections tab', async function () { + it('links database rows to the database collections tab', async function () { await browser.scrollToVirtualItem( Selectors.DatabasesTable, - Selectors.databaseCard('test'), - 'grid' + Selectors.databaseRow('test'), + 'table' + ); + await browser.clickVisible( + `${Selectors.databaseRow('test')} td:first-child` ); - // Click on the db name text inside the card specifically to try and have - // tighter control over where it clicks, because clicking in the center of - // the last card if all cards don't fit on screen can silently do nothing - // even after scrolling it into view. - await browser.clickVisible(Selectors.databaseCardClickable('test'), { - scroll: true, - screenshot: 'database-card.png', - }); const collectionSelectors = ['json-array', 'json-file', 'numbers'].map( - (collectionName) => Selectors.collectionCard('test', collectionName) + (collectionName) => Selectors.collectionRow('test', collectionName) ); for (const collectionSelector of collectionSelectors) { const found = await browser.scrollToVirtualItem( - Selectors.CollectionsGrid, + Selectors.CollectionsTable, collectionSelector, - 'grid' + 'table' ); expect(found, collectionSelector).to.be.true; } @@ -110,21 +105,19 @@ describe('Instance databases tab', function () { 'Databases' ); - const selector = Selectors.databaseCard(dbName); + const selector = Selectors.databaseRow(dbName); await browser.scrollToVirtualItem( Selectors.DatabasesTable, selector, - 'grid' + 'table' ); - const databaseCard = browser.$(selector); - await databaseCard.waitForDisplayed(); - - await databaseCard.scrollIntoView(false); + const databaseRow = browser.$(selector); + await databaseRow.waitForDisplayed(); await browser.waitUntil(async () => { - // open the drop database modal from the database card - await browser.hover(`${selector} [title="${dbName}"]`); - const el = browser.$(Selectors.DatabaseCardDrop); + // open the drop database modal from the database row + await browser.hover(`${selector}`); + const el = browser.$(Selectors.databaseRowDrop(dbName)); if (await el.isDisplayed()) { return true; } @@ -134,13 +127,13 @@ describe('Instance databases tab', function () { return false; }); - await browser.clickVisible(Selectors.DatabaseCardDrop); + await browser.clickVisible(Selectors.databaseRowDrop(dbName)); await browser.dropNamespace(dbName); // wait for it to be gone (which it will be anyway because the app should // redirect back to the databases tab) - await databaseCard.waitForExist({ reverse: true }); + await databaseRow.waitForExist({ reverse: true }); // the app should stay on the instance Databases tab. await browser.waitUntilActiveConnectionTab( @@ -151,7 +144,7 @@ describe('Instance databases tab', function () { it('can refresh the list of databases using refresh controls', async function () { const db = 'test'; // added by beforeEach - const dbSelector = Selectors.databaseCard(db); + const dbSelector = Selectors.databaseRow(db); // Browse to the databases tab await browser.navigateToConnectionTab( @@ -159,11 +152,11 @@ describe('Instance databases tab', function () { 'Databases' ); - // Make sure the db card we're going to drop is in there. + // Make sure the db row we're going to drop is in there. await browser.scrollToVirtualItem( Selectors.DatabasesTable, dbSelector, - 'grid' + 'table' ); await browser.$(dbSelector).waitForDisplayed(); @@ -194,7 +187,7 @@ describe('Instance databases tab', function () { await mongoClient.close(); } - // Refresh again and the database card should disappear. + // Refresh again and the database row should disappear. await browser.clickVisible(Selectors.InstanceRefreshDatabaseButton, { scroll: true, screenshot: 'instance-refresh-database-button.png', diff --git a/packages/compass-web/webpack.config.js b/packages/compass-web/webpack.config.js index b561dd3744e..da27f7e82c5 100644 --- a/packages/compass-web/webpack.config.js +++ b/packages/compass-web/webpack.config.js @@ -226,6 +226,14 @@ module.exports = (env, args) => { ), } : {}), + ...(process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING + ? { + 'process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING': + JSON.stringify( + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING + ), + } + : {}), }), new webpack.ProvidePlugin({ diff --git a/packages/databases-collections-list/src/collections.spec.tsx b/packages/databases-collections-list/src/collections.spec.tsx new file mode 100644 index 00000000000..cbb82d60b1a --- /dev/null +++ b/packages/databases-collections-list/src/collections.spec.tsx @@ -0,0 +1,557 @@ +import React from 'react'; +import { + render, + screen, + cleanup, + userEvent, + waitFor, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { CollectionsList } from './index'; +import Sinon from 'sinon'; +import { + type PreferencesAccess, + PreferencesProvider, +} from 'compass-preferences-model/provider'; +import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; +import type { CollectionProps } from 'mongodb-collection-model'; + +import { inspectTable, testSortColumn } from '../test/utils'; + +function createCollection( + name: string, + props: Partial = {} +): CollectionProps { + const col = { + _id: name, + name: name, + type: 'collection' as const, + status: 'ready' as const, + statusError: null, + ns: `db.${name}`, + database: 'db', + system: true, + oplog: true, + command: true, + special: false, + specialish: false, + normal: false, + readonly: false, + view_on: null, + collation: '', + pipeline: [], + validation: '', + properties: [], + is_capped: false, + isTimeSeries: false, + isView: false, + inferred_from_privileges: false, + /** Only relevant for a view and identifies collection/view from which this view was created. */ + sourceName: null, + source: {} as any, + // collStats + document_count: 10, + document_size: 11, + avg_document_size: 150, + storage_size: 2500, + free_storage_size: 1000, + index_count: 15, + index_size: 16, + calculated_storage_size: undefined, + ...props, + }; + + return col; +} + +function createTimeSeries( + name: string, + props: Partial = {} +): CollectionProps { + return { + ...createCollection(name, props), + type: 'timeseries' as const, + }; +} +const colls: CollectionProps[] = [ + createCollection('foo', { + storage_size: 1000, + document_count: 10, + avg_document_size: 100, + index_count: 5, + index_size: 500, + }), + createCollection('garply', { + storage_size: 1000, + document_count: 0, + avg_document_size: 0, + index_count: 0, + index_size: 0, + inferred_from_privileges: true, + }), + createCollection('bar', { + storage_size: undefined, + document_count: undefined, + avg_document_size: undefined, + index_count: undefined, + index_size: undefined, + type: 'view', + view_on: 'foo', + properties: [{ id: 'view' }], + }), + createTimeSeries('baz', { + storage_size: 5000, + document_count: undefined, + avg_document_size: undefined, + index_size: undefined, + type: 'timeseries', + index_count: undefined, + properties: [{ id: 'timeseries' }], + }), + createCollection('qux', { + storage_size: 7000, + document_count: undefined, + avg_document_size: undefined, + index_count: 5, + index_size: 17000, + properties: [{ id: 'capped' }], + }), + createCollection('quux', { + storage_size: 6000, + document_count: undefined, + avg_document_size: undefined, + index_count: 1, + index_size: 10000000, + properties: [{ id: 'collation' }], + }), + createCollection('corge', { + storage_size: 4000, + document_count: undefined, + avg_document_size: undefined, + index_count: 11, + index_size: 555, + properties: [{ id: 'clustered' }], + }), + createCollection('grault', { + storage_size: 2000, + document_count: undefined, + avg_document_size: undefined, + index_count: 3, + index_size: 333333, + properties: [{ id: 'fle2' }], + }), + createCollection('waldo', { + storage_size: 100, + document_count: 27, + avg_document_size: 10000, + index_count: 5, + index_size: 123456, + }), + createCollection('fred', { + storage_size: 200, + document_count: 13, + avg_document_size: 5000, + index_count: 17, + index_size: 200000, + }), +]; + +describe('Collections', () => { + let preferences: PreferencesAccess; + + before(() => { + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + }); + + beforeEach(async function () { + preferences = await createSandboxFromDefaultPreferences(); + }); + + afterEach(cleanup); + + const renderCollectionsList = ( + props: Partial> + ) => { + const clickSpy = Sinon.spy(); + const deleteSpy = Sinon.spy(); + const createSpy = Sinon.spy(); + const refreshSpy = Sinon.spy(); + render( + + + + ); + + return { + clickSpy, + deleteSpy, + createSpy, + refreshSpy, + }; + }; + + it('should render the collection list', () => { + const { clickSpy, deleteSpy, createSpy, refreshSpy } = + renderCollectionsList({ + collections: colls, + }); + + const result = inspectTable(screen, 'collections-list'); + + expect(result.list).to.exist; + + expect(result.table).to.have.lengthOf(10); + + expect(result.columns).to.deep.equal([ + 'Collection name', + 'Properties', + 'Storage size', + 'Documents', + 'Avg. document size', + 'Indexes', + 'Total index size', + '', // Actions + ]); + + userEvent.click(screen.getByText('Create collection')); + expect(createSpy.calledOnce).to.be.true; + + userEvent.click(screen.getByText('Refresh')); + expect(refreshSpy.calledOnce).to.be.true; + + expect(createSpy.calledOnce).to.be.true; + + userEvent.click(screen.getByTestId('collections-list-row-foo')); + expect(clickSpy.calledOnce).to.be.true; + + const row = screen.getByTestId('collections-list-row-foo'); + userEvent.hover(row); + + const deleteButton = row.querySelector('[title="Delete foo"]'); + expect(deleteButton).to.exist; + userEvent.click(deleteButton as Element); + expect(deleteSpy.calledOnce).to.be.true; + }); + + it('sorts by Collection name', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Collection name', [ + [ + 'foo', + 'garply', + 'bar', + 'baz', + 'qux', + 'quux', + 'corge', + 'grault', + 'waldo', + 'fred', + ], + [ + 'bar', + 'baz', + 'corge', + 'foo', + 'fred', + 'garply', + 'grault', + 'quux', + 'qux', + 'waldo', + ], + [ + 'waldo', + 'qux', + 'quux', + 'grault', + 'garply', + 'fred', + 'foo', + 'corge', + 'baz', + 'bar', + ], + ]); + }); + + it('sorts by Properties', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Properties', [ + [ + '-', + '-', + 'view', + 'timeseries', + 'capped', + 'collation', + 'clustered', + 'Queryable Encryption', + '-', + '-', + ], + [ + 'view', + 'timeseries', + 'capped', + 'collation', + 'clustered', + 'Queryable Encryption', + '-', + '-', + '-', + '-', + ], + [ + '-', + '-', + '-', + '-', + 'Queryable Encryption', + 'clustered', + 'collation', + 'capped', + 'timeseries', + 'view', + ], + ]); + }); + it('sorts by Storage size', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Storage size', [ + [ + '1.00 kB', + '1.00 kB', + '-', // views don't use storage size + '5.00 kB', + '7.00 kB', + '6.00 kB', + '4.00 kB', + '2.00 kB', + '100.00 B', + '200.00 B', + ], + [ + '7.00 kB', + '6.00 kB', + '5.00 kB', + '4.00 kB', + '2.00 kB', + '1.00 kB', + '1.00 kB', + '200.00 B', + '100.00 B', + '-', + ], + [ + '100.00 B', + '200.00 B', + '1.00 kB', + '1.00 kB', + '2.00 kB', + '4.00 kB', + '5.00 kB', + '6.00 kB', + '7.00 kB', + '-', + ], + ]); + }); + + it('sorts by Documents', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Documents', [ + ['10', '0', '-', '-', '-', '-', '-', '-', '27', '13'], + ['27', '13', '10', '0', '-', '-', '-', '-', '-', '-'], + ['0', '10', '13', '27', '-', '-', '-', '-', '-', '-'], + ]); + }); + + it('sorts by Avg. document size', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Avg. document size', [ + ['100.00 B', '0 B', '-', '-', '-', '-', '-', '-', '10.00 kB', '5.00 kB'], + ['10.00 kB', '5.00 kB', '100.00 B', '0 B', '-', '-', '-', '-', '-', '-'], + ['0 B', '100.00 B', '5.00 kB', '10.00 kB', '-', '-', '-', '-', '-', '-'], + ]); + }); + + it('sorts by Indexes', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Indexes', [ + ['5', '0', '-', '-', '5', '1', '11', '3', '5', '17'], + ['17', '11', '5', '5', '5', '3', '1', '0', '-', '-'], + ['0', '1', '3', '5', '5', '5', '11', '17', '-', '-'], + ]); + }); + + it('sorts by Total index size', async function () { + renderCollectionsList({ + collections: colls, + }); + + await testSortColumn(screen, 'collections-list', 'Total index size', [ + [ + '500.00 B', + '0 B', + '-', + '-', + '17.00 kB', + '10.00 MB', + '555.00 B', + '333.33 kB', + '123.46 kB', + '200.00 kB', + ], + [ + '10.00 MB', + '333.33 kB', + '200.00 kB', + '123.46 kB', + '17.00 kB', + '555.00 B', + '500.00 B', + '0 B', + '-', + '-', + ], + [ + '0 B', + '500.00 B', + '555.00 B', + '17.00 kB', + '123.46 kB', + '200.00 kB', + '333.33 kB', + '10.00 MB', + '-', + '-', + ], + ]); + }); + + it('does not render stats with enableDbAndCollStats disabled', async function () { + await preferences.savePreferences({ enableDbAndCollStats: false }); + + renderCollectionsList({ + collections: colls, + }); + + const result = inspectTable(screen, 'collections-list'); + expect(result.table).to.deep.equal([ + ['foo', '-', '-', '-', '-', '-', '-', ''], + ['garply', '-', '-', '-', '-', '-', '-', ''], + ['bar', 'view', '-', '-', '-', '-', '-', ''], + ['baz', 'timeseries', '-', '-', '-', '-', '-', ''], + ['qux', 'capped', '-', '-', '-', '-', '-', ''], + ['quux', 'collation', '-', '-', '-', '-', '-', ''], + ['corge', 'clustered', '-', '-', '-', '-', '-', ''], + ['grault', 'Queryable Encryption', '-', '-', '-', '-', '-', ''], + ['waldo', '-', '-', '-', '-', '-', '-', ''], + ['fred', '-', '-', '-', '-', '-', '-', ''], + ]); + }); + + it('renders loaders while still loading data', function () { + renderCollectionsList({ + collections: colls.map((coll) => { + return { ...coll, status: 'fetching' as const }; + }), + }); + + const result = inspectTable(screen, 'collections-list'); + expect(result.table).to.deep.equal([ + ['foo', '', '', '', '', '', '', ''], + ['garply', '', '', '', '', '', '', ''], + ['bar', '', '', '', '', '', '', ''], + ['baz', '', '', '', '', '', '', ''], + ['qux', '', '', '', '', '', '', ''], + ['quux', '', '', '', '', '', '', ''], + ['corge', '', '', '', '', '', '', ''], + ['grault', '', '', '', '', '', '', ''], + ['waldo', '', '', '', '', '', '', ''], + ['fred', '', '', '', '', '', '', ''], + ]); + expect( + result.list.querySelectorAll('[data-testid="placeholder"]') + ).to.have.lengthOf(60); + }); + + it('renders a tooltip when inferred_from_privileges is true', async function () { + renderCollectionsList({ + collections: colls, + }); + + const result = inspectTable(screen, 'collections-list'); + const icon = result.trs[1].querySelector( + '[aria-label="Info With Circle Icon"]' + ); + expect(icon).to.exist; + + userEvent.hover(icon as Element); + await waitFor( + function () { + expect(screen.getByRole('tooltip')).to.exist; + }, + { + timeout: 5000, + } + ); + + expect(screen.getByRole('tooltip').textContent).to.equal( + 'Your privileges grant you access to this namespace, but it might not currently exist' + ); + }); + + it('renders a tooltip for a view badge', async function () { + renderCollectionsList({ + collections: colls, + }); + + const result = inspectTable(screen, 'collections-list'); + const badge = result.trs[2].querySelector( + '[data-testid="collection-badge-view"]' + ); + expect(badge).to.exist; + + userEvent.hover(badge as Element); + await waitFor( + function () { + expect(screen.getByRole('tooltip')).to.exist; + }, + { + timeout: 5000, + } + ); + + expect(screen.getByRole('tooltip').textContent).to.equal( + 'Derived from foo' + ); + }); +}); diff --git a/packages/databases-collections-list/src/collections.tsx b/packages/databases-collections-list/src/collections.tsx index 4281490f1aa..baf6c30ddbf 100644 --- a/packages/databases-collections-list/src/collections.tsx +++ b/packages/databases-collections-list/src/collections.tsx @@ -1,32 +1,104 @@ -import React from 'react'; +import React, { useCallback } from 'react'; +import type { BadgeVariant, GlyphName } from '@mongodb-js/compass-components'; import { + Badge, + cx, css, + Icon, spacing, + type LGColumnDef, + Tooltip, + palette, + useDarkMode, + Placeholder, compactBytes, compactNumber, } from '@mongodb-js/compass-components'; -import type { BadgeProp } from './namespace-card'; -import { NamespaceItemCard } from './namespace-card'; -import { ItemsGrid } from './items-grid'; +import { ItemsTable, VirtualItemsTable } from './items-table'; import type { CollectionProps } from 'mongodb-collection-model'; import { usePreference } from 'compass-preferences-model/provider'; -const COLLECTION_CARD_WIDTH = spacing[1600] * 4; +type BadgeProp = { + id: string; + name: string; + variant?: BadgeVariant; + icon?: GlyphName; + hint?: React.ReactNode; +}; + +const collectionBadgesStyles = css({ + display: 'flex', + gap: spacing[200], + // Preserving space for when cards with and without badges are mixed in a + // single row + minHeight: 20, +}); + +const CollectionBadges: React.FunctionComponent = ({ children }) => { + return
{children}
; +}; + +const collectionBadgeStyles = css({ + gap: spacing[100], + 'white-space': 'nowrap', +}); + +const viewOnStyles = css({ + fontWeight: 'bold', +}); -const COLLECTION_CARD_HEIGHT = 238; -const COLLECTION_CARD_WITHOUT_STATS_HEIGHT = COLLECTION_CARD_HEIGHT - 150; +const viewOnLightStyles = css({ + color: palette.white, +}); -const COLLECTION_CARD_LIST_HEIGHT = 118; -const COLLECTION_CARD_LIST_WITHOUT_STATS_HEIGHT = - COLLECTION_CARD_LIST_HEIGHT - 50; +const viewOnDarkStyles = css({ + color: palette.black, +}); -function collectionPropertyToBadge({ +const CollectionBadge: React.FunctionComponent = ({ id, - options, -}: { - id: string; - options?: Record; -}): BadgeProp { + name, + icon, + variant, + hint, +}) => { + const badge = useCallback( + ({ className, children, ...props } = {}) => { + return ( + + {icon && } + {name} + {/* Tooltip will be rendered here */} + {children} + + ); + }, + [id, icon, name, variant] + ); + + if (hint) { + return {hint}; + } + + return badge(); +}; + +function collectionPropertyToBadge( + collection: CollectionProps, + darkMode: boolean | undefined, + { + id, + options, + }: { + id: string; + options?: Record; + } +): BadgeProp { switch (id) { case 'collation': return { @@ -35,19 +107,38 @@ function collectionPropertyToBadge({ variant: 'darkgray', hint: ( <> - {Object.entries(options ?? {}).map( - ([key, val]) => + {Object.entries(options ?? {}).map(([key, val]) => { + return ( val && (
- {key}: {val} + {key}: {val.toString()}
) - )} + ); + })} ), }; case 'view': - return { id, name: id, variant: 'darkgray', icon: 'Visibility' }; + return { + id, + name: id, + variant: 'darkgray', + icon: 'Visibility', + hint: ( + <> + Derived from{' '} + + {collection.view_on} + + + ), + }; case 'capped': return { id, name: id, variant: 'darkgray' }; case 'timeseries': @@ -66,13 +157,260 @@ function collectionPropertyToBadge({ } } -const pageContainerStyles = css({ - height: 'auto', - width: '100%', +const collectionNameWrapStyles = css({ display: 'flex', - flexDirection: 'column', + gap: spacing[100], + flexWrap: 'wrap', + alignItems: 'anchor-center', + wordBreak: 'break-word', }); +const tooltipTriggerStyles = css({ + display: 'flex', +}); + +const inferredFromPrivilegesLightStyles = css({ + color: palette.gray.dark1, +}); + +const inferredFromPrivilegesDarkStyles = css({ + color: palette.gray.base, +}); + +function isReady( + status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error' +) { + /* + yes: + * refreshing + * ready + * error + + no: + * initial + * fetching + */ + + return status !== 'initial' && status !== 'fetching'; +} + +function collectionColumns({ + darkMode, + enableDbAndCollStats, +}: { + darkMode: boolean | undefined; + enableDbAndCollStats: boolean; +}): LGColumnDef[] { + return [ + { + accessorKey: 'name', + header: 'Collection name', + enableSorting: true, + sortUndefined: 'last', + minSize: 250, + cell: (info) => { + const collection = info.row.original; + const name = collection.name; + + return ( +
+ + {name} + + {collection.inferred_from_privileges && ( + + +
+ } + > + Your privileges grant you access to this namespace, but it might + not currently exist + + )} + + ); + }, + }, + { + accessorKey: 'properties', + header: 'Properties', + enableSorting: true, + sortUndefined: 'last', + cell: (info) => { + const collection = info.row.original; + + if (!isReady(collection.status)) { + return ; + } + + const badges = collection.properties.map((prop) => { + return collectionPropertyToBadge(collection, darkMode, prop); + }); + + if (badges.length === 0) { + return '-'; + } + + return ( + + {badges.map((badge) => { + return ( + + ); + })} + + ); + }, + }, + { + accessorKey: 'storage_size', + header: 'Storage size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + const type = collection.type as string; + if (type === 'view') { + return '-'; + } + return enableDbAndCollStats && collection.storage_size !== undefined + ? compactBytes(collection.storage_size) + : '-'; + }, + }, + /* + { + accessorKey: 'free_storage_size', + header: 'Free storage size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 100, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + const type = collection.type as string; + if (type === 'view') { + return '-'; + } + return enableDbAndCollStats && collection.free_storage_size !== undefined + ? compactBytes(collection.free_storage_size) + : '-'; + }, + }, + */ + { + accessorKey: 'document_count', + header: 'Documents', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + const type = collection.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + return enableDbAndCollStats && collection.document_count !== undefined + ? compactNumber(collection.document_count) + : '-'; + }, + }, + { + accessorKey: 'avg_document_size', + header: 'Avg. document size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 110, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + const type = collection.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + return enableDbAndCollStats && + collection.avg_document_size !== undefined + ? compactBytes(collection.avg_document_size) + : '-'; + }, + }, + { + accessorKey: 'index_count', + header: 'Indexes', + enableSorting: true, + sortUndefined: 'last', + maxSize: 60, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + const type = collection.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + return enableDbAndCollStats && collection.index_count !== undefined + ? compactNumber(collection.index_count) + : '-'; + }, + }, + { + accessorKey: 'index_size', + header: 'Total index size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 100, + cell: (info) => { + const collection = info.row.original; + if (!isReady(collection.status)) { + return ; + } + + if (collection.type === 'view' || collection.type === 'timeseries') { + return '-'; + } + + const size = collection.index_size; + return enableDbAndCollStats && size !== undefined + ? compactBytes(size) + : '-'; + }, + }, + ]; +} + const CollectionsList: React.FunctionComponent<{ namespace: string; collections: CollectionProps[]; @@ -84,129 +422,35 @@ const CollectionsList: React.FunctionComponent<{ namespace, collections, onCollectionClick, - onCreateCollectionClick, onDeleteCollectionClick, + onCreateCollectionClick, onRefreshClick, }) => { + let virtual = true; + if (process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING === 'true') { + virtual = false; + } + const enableDbAndCollStats = usePreference('enableDbAndCollStats'); + const darkMode = useDarkMode(); + const columns = React.useMemo( + () => collectionColumns({ darkMode, enableDbAndCollStats }), + [darkMode, enableDbAndCollStats] + ); + + const TableComponent = virtual ? VirtualItemsTable : ItemsTable; return ( -
- { - const data = - coll.type === 'view' - ? [{ label: 'View on', value: coll.source?.name }] - : coll.type === 'timeseries' - ? [ - { - label: 'Storage size', - value: - coll.calculated_storage_size !== undefined - ? compactBytes(coll.calculated_storage_size) - : 'N/A', - hint: - coll.document_size !== undefined && - `Uncompressed data size: ${compactBytes( - coll.document_size - )}`, - }, - ] - : [ - { - label: 'Storage size', - value: - coll.calculated_storage_size !== undefined - ? compactBytes(coll.calculated_storage_size) - : 'N/A', - hint: - coll.document_size !== undefined && - `Uncompressed data size: ${compactBytes( - coll.document_size - )}`, - }, - { - label: 'Documents', - value: - coll.document_count !== undefined - ? compactNumber(coll.document_count) - : 'N/A', - }, - { - label: 'Avg. document size', - value: - coll.avg_document_size !== undefined - ? compactBytes(coll.avg_document_size) - : 'N/A', - }, - { - label: 'Indexes', - value: - coll.index_count !== undefined - ? compactNumber(coll.index_count) - : 'N/A', - }, - { - label: 'Total index size', - value: - coll.index_size !== undefined - ? compactBytes(coll.index_size) - : 'N/A', - }, - ]; - - const badges = coll.properties.map((prop) => { - return collectionPropertyToBadge(prop); - }); - - return ( - - ); - }} - > -
+ + data-testid="collections-list" + namespace={namespace} + columns={columns} + items={collections} + itemType="collection" + onItemClick={onCollectionClick} + onDeleteItemClick={onDeleteCollectionClick} + onCreateItemClick={onCreateCollectionClick} + onRefreshClick={onRefreshClick} + > ); }; diff --git a/packages/databases-collections-list/src/databases.spec.tsx b/packages/databases-collections-list/src/databases.spec.tsx new file mode 100644 index 00000000000..e9ea5b83a98 --- /dev/null +++ b/packages/databases-collections-list/src/databases.spec.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { + render, + screen, + cleanup, + userEvent, + waitFor, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { DatabasesList } from './index'; +import Sinon from 'sinon'; +import { + type PreferencesAccess, + PreferencesProvider, +} from 'compass-preferences-model/provider'; +import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; +import type { DatabaseProps } from 'mongodb-database-model'; +import { inspectTable, testSortColumn } from '../test/utils'; + +function createDatabase(name: string): DatabaseProps { + const db: DatabaseProps = { + _id: name, + name: name, + status: 'ready', + statusError: null, + collectionsLength: 35, + collectionsStatus: 'ready', + collectionsStatusError: null, + collection_count: 1, + collections: [] as any, + inferred_from_privileges: false, + // dbStats + document_count: 10, + storage_size: 1500, + data_size: 1000, + index_count: 25, + index_size: 100, + }; + return db; +} + +const dbs: DatabaseProps[] = [ + { + ...createDatabase('foo'), + storage_size: 5000, + collectionsLength: 5, + index_count: 5, + }, + { + ...createDatabase('bar'), + storage_size: 0, + collectionsLength: 1, + index_count: 10, + inferred_from_privileges: true, + }, + { + ...createDatabase('buz'), + storage_size: 10000, + collectionsLength: 10_001, + index_count: 12, + }, + { + ...createDatabase('bat'), + storage_size: 7500, + collectionsLength: 7, + index_count: 9, + }, +]; + +describe('Databases', function () { + let preferences: PreferencesAccess; + + before(() => { + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + }); + + beforeEach(async function () { + preferences = await createSandboxFromDefaultPreferences(); + }); + + afterEach(cleanup); + + const renderDatabasesList = ( + props: Partial> + ) => { + const clickSpy = Sinon.spy(); + const deleteSpy = Sinon.spy(); + const createSpy = Sinon.spy(); + const refreshSpy = Sinon.spy(); + + render( + + + + ); + + return { + clickSpy, + deleteSpy, + createSpy, + refreshSpy, + }; + }; + + it('should render the database list', function () { + const { clickSpy, deleteSpy, createSpy, refreshSpy } = renderDatabasesList({ + databases: dbs, + }); + + const result = inspectTable(screen, 'databases-list'); + + expect(result.list).to.exist; + + expect(result.table).to.have.lengthOf(4); + + expect(result.columns).to.deep.equal([ + 'Database name', + 'Storage size', + 'Collections', + 'Indexes', + '', // Actions + ]); + + userEvent.click(screen.getByText('Create database')); + expect(createSpy.calledOnce).to.be.true; + + userEvent.click(screen.getByText('Refresh')); + expect(refreshSpy.calledOnce).to.be.true; + + expect(createSpy.calledOnce).to.be.true; + + userEvent.click(screen.getByTestId('databases-list-row-foo')); + expect(clickSpy.calledOnce).to.be.true; + + const row = screen.getByTestId('databases-list-row-foo'); + userEvent.hover(row); + + const deleteButton = row.querySelector('[title="Delete foo"]'); + expect(deleteButton).to.exist; + userEvent.click(deleteButton as Element); + expect(deleteSpy.calledOnce).to.be.true; + }); + + it('sorts by "Database name"', async function () { + renderDatabasesList({ + databases: dbs, + }); + + await testSortColumn(screen, 'databases-list', 'Database name', [ + ['foo', 'bar', 'buz', 'bat'], + ['bar', 'bat', 'buz', 'foo'], + ['foo', 'buz', 'bat', 'bar'], + ]); + }); + + it('sorts by "Storage size"', async function () { + renderDatabasesList({ + databases: dbs, + }); + + await testSortColumn(screen, 'databases-list', 'Storage size', [ + ['5.00 kB', '0 B', '10.00 kB', '7.50 kB'], + ['10.00 kB', '7.50 kB', '5.00 kB', '0 B'], + ['0 B', '5.00 kB', '7.50 kB', '10.00 kB'], + ]); + }); + + it('sorts by "Collections"', async function () { + renderDatabasesList({ + databases: dbs, + }); + + await testSortColumn(screen, 'databases-list', 'Collections', [ + ['5', '1', '10K insight', '7'], + ['10K insight', '7', '5', '1'], + ['1', '5', '7', '10K insight'], + ]); + }); + + it('sorts by "Indexes"', async function () { + renderDatabasesList({ + databases: dbs, + }); + + await testSortColumn(screen, 'databases-list', 'Indexes', [ + ['5', '10', '12', '9'], + ['12', '10', '9', '5'], + ['5', '9', '10', '12'], + ]); + }); + + it('renders renderLoadSampleDataBanner() if provided', function () { + renderDatabasesList({ + databases: dbs, + renderLoadSampleDataBanner: () =>
Sample Data Banner
, + }); + + expect(screen.getByText('Sample Data Banner')).to.exist; + }); + + it('renders performance insights', function () { + renderDatabasesList({ + databases: dbs, + }); + expect(screen.getByTestId('insight-badge-text')).to.exist; + }); + + it('does not render stats with enableDbAndCollStats disabled', async function () { + await preferences.savePreferences({ enableDbAndCollStats: false }); + + renderDatabasesList({ + databases: dbs, + }); + + const result = inspectTable(screen, 'databases-list'); + expect(result.table).to.deep.equal([ + ['foo', '-', '-', '-', ''], + ['bar', '-', '-', '-', ''], + ['buz', '-', '-', '-', ''], + ['bat', '-', '-', '-', ''], + ]); + }); + + it('renders loaders while still loading data', function () { + renderDatabasesList({ + databases: dbs.map((db) => { + return { ...db, status: 'fetching' as const }; + }), + }); + + const result = inspectTable(screen, 'databases-list'); + expect(result.table).to.deep.equal([ + ['foo', '', '', '', ''], + ['bar', '', '', '', ''], + ['buz', '', '', '', ''], + ['bat', '', '', '', ''], + ]); + expect( + result.list.querySelectorAll('[data-testid="placeholder"]') + ).to.have.lengthOf(12); + }); + + it('renders a tooltip when inferred_from_privileges is true', async function () { + renderDatabasesList({ + databases: dbs, + }); + + const result = inspectTable(screen, 'databases-list'); + const icon = result.trs[1].querySelector( + '[aria-label="Info With Circle Icon"]' + ); + expect(icon).to.exist; + + userEvent.hover(icon as Element); + await waitFor( + function () { + expect(screen.getByRole('tooltip')).to.exist; + }, + { + timeout: 5000, + } + ); + + expect(screen.getByRole('tooltip').textContent).to.equal( + 'Your privileges grant you access to this namespace, but it might not currently exist' + ); + }); +}); diff --git a/packages/databases-collections-list/src/databases.tsx b/packages/databases-collections-list/src/databases.tsx index f9e6c2ebce1..27ac853de10 100644 --- a/packages/databases-collections-list/src/databases.tsx +++ b/packages/databases-collections-list/src/databases.tsx @@ -1,23 +1,207 @@ /* eslint-disable react/prop-types */ import React from 'react'; +import { ItemsTable, VirtualItemsTable } from './items-table'; +import type { DatabaseProps } from 'mongodb-database-model'; +import { usePreference } from 'compass-preferences-model/provider'; +import type { LGColumnDef } from '@mongodb-js/compass-components'; import { + css, + cx, + Icon, + palette, PerformanceSignals, + Placeholder, + SignalPopover, spacing, + Tooltip, + useDarkMode, compactBytes, compactNumber, } from '@mongodb-js/compass-components'; -import { NamespaceItemCard } from './namespace-card'; -import { ItemsGrid } from './items-grid'; -import type { DatabaseProps } from 'mongodb-database-model'; -import { usePreference } from 'compass-preferences-model/provider'; -const DATABASE_CARD_WIDTH = spacing[1600] * 4; +const databaseNameWrapStyles = css({ + display: 'flex', + gap: spacing[100], + flexWrap: 'wrap', + alignItems: 'anchor-center', + wordBreak: 'break-word', +}); + +const tooltipTriggerStyles = css({ + display: 'flex', +}); + +const inferredFromPrivilegesLightStyles = css({ + color: palette.gray.dark1, +}); + +const inferredFromPrivilegesDarkStyles = css({ + color: palette.gray.base, +}); + +const collectionsLengthWrapStyles = css({ + display: 'flex', + gap: spacing[100], + flexWrap: 'wrap', + alignItems: 'anchor-center', +}); + +const collectionsLengthStyles = css({}); + +function isReady( + status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error' +) { + /* + yes: + * refreshing + * ready + * error + + no: + * initial + * fetching + */ + + return status !== 'initial' && status !== 'fetching'; +} + +function databaseColumns({ + darkMode, + enableDbAndCollStats, + showInsights, +}: { + darkMode: boolean | undefined; + enableDbAndCollStats: boolean; + showInsights?: boolean; +}): LGColumnDef[] { + return [ + { + accessorKey: 'name', + header: 'Database name', + enableSorting: true, + sortUndefined: 'last', + minSize: 300, + cell: (info) => { + const database = info.row.original; + const name = database.name; + return ( + + + {name} + + + {database.inferred_from_privileges && ( + + + + } + > + Your privileges grant you access to this namespace, but it might + not currently exist + + )} + + ); + }, + }, + { + accessorKey: 'storage_size', + header: 'Storage size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const database = info.row.original; + if (!isReady(database.status)) { + return ; + } + + return enableDbAndCollStats && database.storage_size !== undefined + ? compactBytes(database.storage_size) + : '-'; + }, + }, + /* + { + accessorKey: 'data_size', + header: 'Data size', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const database = info.row.original; + if (!isReady(database.status)) { + return ; + } + + return enableDbAndCollStats && database.data_size !== undefined + ? compactBytes(database.data_size) + : '-'; + }, + }, + */ + { + accessorKey: 'collectionsLength', + header: 'Collections', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const database = info.row.original; + if (!isReady(database.status)) { + return ; + } -const DATABASE_CARD_HEIGHT = 154; -const DATABASE_CARD_WITHOUT_STATS_HEIGHT = DATABASE_CARD_HEIGHT - 85; + const text = enableDbAndCollStats + ? compactNumber(database.collectionsLength) + : '-'; + + return ( + + {text} + {showInsights && + enableDbAndCollStats && + (info.getValue() as number) > 10_000 && ( + + )} + + ); + }, + }, + { + accessorKey: 'index_count', + header: 'Indexes', + enableSorting: true, + sortUndefined: 'last', + maxSize: 80, + cell: (info) => { + const database = info.row.original; + if (!isReady(database.status)) { + return ; + } -const DATABASE_CARD_LIST_HEIGHT = 118; -const DATABASE_CARD_LIST_WITHOUT_STATS_HEIGHT = DATABASE_CARD_LIST_HEIGHT - 50; + return enableDbAndCollStats && database.index_count !== undefined + ? compactNumber(database.index_count) + : '-'; + }, + }, + ]; +} const DatabasesList: React.FunctionComponent<{ databases: DatabaseProps[]; @@ -29,91 +213,38 @@ const DatabasesList: React.FunctionComponent<{ }> = ({ databases, onDatabaseClick, - onCreateDatabaseClick, onDeleteDatabaseClick, + onCreateDatabaseClick, onRefreshClick, renderLoadSampleDataBanner, }) => { + let virtual = true; + if (process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING === 'true') { + virtual = false; + } + + const showInsights = usePreference('showInsights'); const enableDbAndCollStats = usePreference('enableDbAndCollStats'); + const darkMode = useDarkMode(); + const columns = React.useMemo( + () => databaseColumns({ darkMode, enableDbAndCollStats, showInsights }), + [darkMode, enableDbAndCollStats, showInsights] + ); + + const TableComponent = virtual ? VirtualItemsTable : ItemsTable; return ( - + virtual={virtual} + data-testid="databases-list" + columns={columns} items={databases} itemType="database" - itemGridWidth={DATABASE_CARD_WIDTH} - itemGridHeight={ - enableDbAndCollStats - ? DATABASE_CARD_HEIGHT - : DATABASE_CARD_WITHOUT_STATS_HEIGHT - } - itemListHeight={ - enableDbAndCollStats - ? DATABASE_CARD_LIST_HEIGHT - : DATABASE_CARD_LIST_WITHOUT_STATS_HEIGHT - } - sortBy={[ - { name: 'name', label: 'Database Name' }, - { name: 'storage_size', label: 'Storage size' }, - { name: 'collectionsLength', label: 'Collections' }, - { name: 'index_count', label: 'Indexes' }, - ]} onItemClick={onDatabaseClick} onDeleteItemClick={onDeleteDatabaseClick} onCreateItemClick={onCreateDatabaseClick} onRefreshClick={onRefreshClick} - renderItem={({ - item: db, - onItemClick, - onDeleteItemClick, - viewType, - ...props - }) => { - return ( - = 10_000 - ? PerformanceSignals.get('too-many-collections') - : undefined, - }, - { - label: 'Indexes', - value: - enableDbAndCollStats && db.index_count !== undefined - ? compactNumber(db.index_count) - : 'N/A', - }, - ]} - onItemClick={onItemClick} - onItemDeleteClick={onDeleteItemClick} - {...props} - > - ); - }} renderLoadSampleDataBanner={renderLoadSampleDataBanner} - > + > ); }; diff --git a/packages/databases-collections-list/src/index.spec.tsx b/packages/databases-collections-list/src/index.spec.tsx deleted file mode 100644 index d39d74be7ec..00000000000 --- a/packages/databases-collections-list/src/index.spec.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React from 'react'; -import { - render, - screen, - cleanup, - userEvent, -} from '@mongodb-js/testing-library-compass'; -import { expect } from 'chai'; -import { DatabasesList, CollectionsList } from './index'; -import Sinon from 'sinon'; -import { - type PreferencesAccess, - PreferencesProvider, -} from 'compass-preferences-model/provider'; -import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; -import type { CollectionProps } from 'mongodb-collection-model'; -import type { DatabaseProps } from 'mongodb-database-model'; - -function createDatabase(name: string): DatabaseProps { - const db: DatabaseProps = { - _id: name, - name: name, - status: 'ready' as const, - statusError: null, - collectionsLength: 35, - collectionsStatus: 'ready' as const, - collectionsStatusError: null, - collection_count: 1, - collections: [] as any, - inferred_from_privileges: false, - // dbStats - document_count: 10, - storage_size: 1500, - data_size: 1000, - index_count: 25, - index_size: 100, - }; - return db; -} - -function createCollection( - name: string, - props: Partial = {} -): CollectionProps { - const col = { - _id: name, - name: name, - type: 'collection' as const, - status: 'ready' as const, - statusError: null, - ns: `db.${name}`, - database: 'db', - system: true, - oplog: true, - command: true, - special: false, - specialish: false, - normal: false, - readonly: false, - view_on: null, - collation: '', - pipeline: [], - validation: '', - properties: [], - is_capped: false, - isTimeSeries: false, - isView: false, - inferred_from_privileges: false, - /** Only relevant for a view and identifies collection/view from which this view was created. */ - sourceName: null, - source: {} as any, - // collStats - document_count: 10, - document_size: 11, - avg_document_size: 150, - storage_size: 2500, - free_storage_size: 1000, - index_count: 15, - index_size: 16, - calculated_storage_size: undefined, - ...props, - }; - - if (col.storage_size !== undefined && col.free_storage_size !== undefined) { - col.calculated_storage_size = col.storage_size - col.free_storage_size; - } - - return col; -} - -function createTimeSeries( - name: string, - props: Partial = {} -): CollectionProps { - return { - ...createCollection(name, props), - type: 'timeseries' as const, - }; -} - -const dbs: DatabaseProps[] = [ - createDatabase('foo'), - createDatabase('bar'), - createDatabase('buz'), - createDatabase('bat'), -]; - -const colls: CollectionProps[] = [ - createCollection('foo.foo', { storage_size: 1000, free_storage_size: 1000 }), // 1000 - createCollection('bar.bar', { storage_size: 2000, free_storage_size: 500 }), // 1500 - createCollection('buz.buz', { storage_size: 3000, free_storage_size: 2000 }), // 1000 - createTimeSeries('bat.bat', { storage_size: 4000, free_storage_size: 0 }), // 4000 -]; - -describe('databases and collections list', function () { - describe('DatabasesList', function () { - let preferences: PreferencesAccess; - - beforeEach(async function () { - preferences = await createSandboxFromDefaultPreferences(); - }); - - afterEach(cleanup); - - const renderDatabasesList = ( - props: Partial> - ) => { - render( - - {}} - {...props} - > - - ); - }; - - it('should render databases in a list', function () { - const clickSpy = Sinon.spy(); - - renderDatabasesList({ databases: dbs, onDatabaseClick: clickSpy }); - - expect(screen.getByTestId('database-grid')).to.exist; - - expect(screen.getAllByTestId('database-grid-item')).to.have.lengthOf(4); - - expect(screen.getByText('foo')).to.exist; - expect(screen.getByText('bar')).to.exist; - expect(screen.getByText('buz')).to.exist; - - userEvent.click(screen.getByText('foo')); - - expect(clickSpy).to.be.calledWith('foo'); - }); - - it('should render database with statistics when dbStats are enabled', async function () { - const clickSpy = Sinon.spy(); - - const db = createDatabase('foo'); - await preferences.savePreferences({ enableDbAndCollStats: true }); - - renderDatabasesList({ databases: [db], onDatabaseClick: clickSpy }); - - expect(screen.getByTestId('database-grid')).to.exist; - - expect(screen.getAllByTestId('database-grid-item')).to.have.lengthOf(1); - expect(screen.getByText('foo')).to.exist; - - expect(screen.getByText(/Storage size/)).to.exist; - expect(screen.getByText('1.50 kB')).to.exist; - expect(screen.getByText(/Collections/)).to.exist; - expect(screen.getByText('35')).to.exist; - expect(screen.getByText(/Indexes/)).to.exist; - expect(screen.getByText('25')).to.exist; - }); - - it('should render database without statistics when dbStats are disabled', async function () { - const clickSpy = Sinon.spy(); - - const db = createDatabase('foo'); - await preferences.savePreferences({ enableDbAndCollStats: false }); - - renderDatabasesList({ databases: [db], onDatabaseClick: clickSpy }); - - expect(screen.getByTestId('database-grid')).to.exist; - - expect(screen.getAllByTestId('database-grid-item')).to.have.lengthOf(1); - expect(screen.getByText('foo')).to.exist; - - expect(screen.queryByText(/Storage size/)).not.to.exist; - expect(screen.queryByText('1.50 kB')).not.to.exist; - expect(screen.queryByText(/Collections/)).not.to.exist; - expect(screen.queryByText('35')).not.to.exist; - expect(screen.queryByText(/Indexes/)).not.to.exist; - expect(screen.queryByText('25')).not.to.exist; - }); - }); - - describe('CollectionsList', function () { - let preferences: PreferencesAccess; - - beforeEach(async function () { - preferences = await createSandboxFromDefaultPreferences(); - }); - - afterEach(cleanup); - - const renderCollectionsList = ( - props: Partial> - ) => { - render( - - {}} - namespace="db" - collections={[]} - {...props} - > - - ); - }; - - it('should render collections in a list', function () { - const clickSpy = Sinon.spy(); - - renderCollectionsList({ - namespace: 'db', - collections: colls, - onCollectionClick: clickSpy, - }); - - expect(screen.getByTestId('collection-grid')).to.exist; - - expect(screen.getAllByTestId('collection-grid-item')).to.have.lengthOf(4); - - expect(screen.getByText('foo.foo')).to.exist; - expect(screen.getByText('bar.bar')).to.exist; - expect(screen.getByText('buz.buz')).to.exist; - - userEvent.click(screen.getByText('bar.bar')); - - expect(clickSpy).to.be.calledWith('bar.bar'); - }); - - it('should sort collections', function () { - renderCollectionsList({ - namespace: 'db', - collections: colls, - }); - - screen - .getByRole('button', { - name: 'Sort by', - }) - .click(); - - screen - .getByRole('option', { - name: 'Storage size', - }) - .click(); - - const sorted = screen - .getAllByRole('gridcell') - .map((el: HTMLElement) => el.getAttribute('data-id')); - expect(sorted).to.deep.equal([ - 'foo.foo', - 'buz.buz', - 'bar.bar', - 'bat.bat', - ]); - }); - - it('should not display statistics (except storage size) on timeseries collection card', function () { - renderCollectionsList({ - namespace: 'db', - collections: colls, - onCollectionClick: () => {}, - }); - - const timeseriesCard = screen - .getByText('bat.bat') - .closest('[data-testid="collection-grid-item"]'); - expect(timeseriesCard).to.exist; - expect(timeseriesCard).to.contain.text('Storage size:'); - expect(timeseriesCard).to.not.contain.text('Documents:'); - expect(timeseriesCard).to.not.contain.text('Avg. document size::'); - expect(timeseriesCard).to.not.contain.text('Indexes:'); - expect(timeseriesCard).to.not.contain.text('Total index size:'); - }); - - it('should display statistics when collStats are enabled', async function () { - await preferences.savePreferences({ enableDbAndCollStats: true }); - - const coll = createCollection('bar'); - - renderCollectionsList({ - namespace: 'db', - collections: [coll], - onCollectionClick: () => {}, - }); - - expect(screen.getByText(/Storage size/)).to.exist; - expect(screen.getByText('1.50 kB')).to.exist; - expect(screen.getByText(/Documents/)).to.exist; - expect(screen.getByText('10')).to.exist; - expect(screen.getByText(/Avg. document size/)).to.exist; - expect(screen.getByText('150.00 B')).to.exist; - expect(screen.getByText(/Indexes/)).to.exist; - expect(screen.getByText('15')).to.exist; - expect(screen.getByText(/Total index size/)).to.exist; - expect(screen.getByText('16.00 B')).to.exist; - }); - - it('should not display statistics when collStats are disabled', async function () { - await preferences.savePreferences({ enableDbAndCollStats: false }); - - const coll = createCollection('bar'); - - renderCollectionsList({ - namespace: 'db', - collections: [coll], - onCollectionClick: () => {}, - }); - - expect(screen.queryByText(/Storage size/)).not.to.exist; - expect(screen.queryByText('1.50 kB')).not.to.exist; - expect(screen.queryByText(/Documents/)).not.to.exist; - expect(screen.queryByText('10')).not.to.exist; - expect(screen.queryByText(/Avg. document size/)).not.to.exist; - expect(screen.queryByText('150.00 B')).not.to.exist; - expect(screen.queryByText(/Indexes/)).not.to.exist; - expect(screen.queryByText('15')).not.to.exist; - expect(screen.queryByText(/Total index size/)).not.to.exist; - expect(screen.queryByText('16.00 B')).not.to.exist; - }); - }); -}); diff --git a/packages/databases-collections-list/src/items-grid.tsx b/packages/databases-collections-list/src/items-grid.tsx deleted file mode 100644 index fa1300cfed1..00000000000 --- a/packages/databases-collections-list/src/items-grid.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { - css, - cx, - spacing, - VirtualGrid, - useSortControls, - useSortedItems, - WorkspaceContainer, - Button, - Icon, - Breadcrumbs, -} from '@mongodb-js/compass-components'; -import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; -import type { NamespaceItemCardProps } from './namespace-card'; -import { useViewTypeControls } from './use-view-type'; -import type { ViewType } from './use-view-type'; -import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; -import toNS from 'mongodb-ns'; -import { getConnectionTitle } from '@mongodb-js/connection-info'; -import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; -import { useConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; -import { usePreferences } from 'compass-preferences-model/provider'; - -type Item = { _id: string } & Record; - -const rowStyles = css({ - paddingLeft: spacing[400], - paddingRight: spacing[400], - paddingBottom: spacing[100], - paddingTop: spacing[100], - columnGap: spacing[200], -}); - -const containerStyles = css({ - width: '100%', - height: '100%', - overflow: 'hidden', - display: 'grid', - gridTemplateRows: 'auto 1fr', - gridTemplateColumns: '100%', - // This element is focusable only to handle virtual list and will immediately - // pass focus to its children. This can take a frame though so to avoid - // outline on the container showing up, we are completely disabling it - outline: 'none', -}); - -const gridStyles = { - container: containerStyles, - row: rowStyles, -}; - -export const createButtonStyles = css({ - whiteSpace: 'nowrap', -}); - -type CallbackProps = { - onItemClick: (id: string) => void; - onCreateItemClick?: () => void; - onDeleteItemClick?: (id: string) => void; -}; - -interface RenderItem { - ( - props: { - item: T; - viewType: ViewType; - } & Omit & - Omit< - React.HTMLProps, - Extract - > - ): React.ReactElement; -} - -type ItemsGridProps = { - namespace?: string; - itemType: 'collection' | 'database'; - itemGridWidth: number; - itemGridHeight: number; - itemListWidth?: number; - itemListHeight?: number; - items: T[]; - sortBy?: { name: Extract; label: string }[]; - onItemClick: (id: string) => void; - onDeleteItemClick?: (id: string) => void; - onCreateItemClick?: () => void; - onRefreshClick?: () => void; - renderItem: RenderItem; - renderLoadSampleDataBanner?: () => React.ReactNode; -}; - -const controlsContainerStyles = css({ - paddingTop: spacing[200], - paddingRight: spacing[400], - paddingBottom: spacing[400], - paddingLeft: spacing[400], - - display: 'grid', - gridTemplate: '1fr / 100%', - gap: spacing[200], -}); - -const controlRowStyles = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[200], -}); - -const controlStyles = css({ - flex: 'none', -}); - -const breadcrumbContainerStyles = css({ - display: 'flex', - minWidth: 0, - paddingTop: spacing[200], - paddingBottom: spacing[200], -}); - -const pushRightStyles = css({ - marginLeft: 'auto', -}); - -const bannerRowStyles = css({ - paddingTop: spacing[200], -}); - -function buildChartsUrl( - groupId: string, - clusterName: string, - namespace?: string -) { - const { database } = toNS(namespace ?? ''); - const url = new URL(`/charts/${groupId}`, window.location.origin); - url.searchParams.set('sourceType', 'cluster'); - url.searchParams.set('name', clusterName); - if (database) { - url.searchParams.set('database', database); - } - return url.toString(); -} - -const GridControls: React.FunctionComponent<{ - namespace?: string; - itemType: string; - sortControls?: React.ReactNode; - viewTypeControls?: React.ReactNode; - onCreateItemClick?: () => void; - onRefreshClick?: () => void; - renderLoadSampleDataBanner?: () => React.ReactNode; -}> = ({ - namespace, - itemType, - sortControls, - viewTypeControls, - onCreateItemClick, - onRefreshClick, - renderLoadSampleDataBanner, -}) => { - const connectionInfo = useConnectionInfo(); - const connectionTitle = getConnectionTitle(connectionInfo); - const { - openDatabasesWorkspace, - openCollectionsWorkspace, - openShellWorkspace, - } = useOpenWorkspace(); - const track = useTelemetry(); - const { enableShell: showOpenShellButton } = usePreferences(['enableShell']); - - const breadcrumbs = useMemo(() => { - const { database } = toNS(namespace ?? ''); - const items = [ - { - name: connectionTitle, - onClick: () => { - openDatabasesWorkspace(connectionInfo.id); - }, - }, - ]; - - if (database) { - items.push({ - name: database, - onClick: () => { - openCollectionsWorkspace(connectionInfo.id, database); - }, - }); - } - - return items; - }, [ - connectionInfo.id, - connectionTitle, - namespace, - openCollectionsWorkspace, - openDatabasesWorkspace, - ]); - - const banner = renderLoadSampleDataBanner?.(); - - return ( -
-
-
- -
- -
- {showOpenShellButton && ( - - )} - - {connectionInfo.atlasMetadata && ( - - )} - - {onCreateItemClick && ( -
- -
- )} - - {onRefreshClick && ( -
- -
- )} -
-
- {sortControls && viewTypeControls && ( -
-
{sortControls}
-
- {viewTypeControls} -
-
- )} - {banner &&
{banner}
} -
- ); -}; - -const itemsGridContainerStyles = css({ - width: '100%', - height: '100%', -}); - -export const ItemsGrid = ({ - namespace, - itemType, - itemGridWidth, - itemGridHeight, - itemListWidth = itemGridWidth, - itemListHeight = itemGridHeight, - items, - sortBy = [], - onItemClick, - onDeleteItemClick, - onCreateItemClick, - onRefreshClick, - renderItem: _renderItem, - renderLoadSampleDataBanner, -}: ItemsGridProps): React.ReactElement => { - const track = useTelemetry(); - const connectionInfoRef = useConnectionInfoRef(); - const onViewTypeChange = useCallback( - (newType: ViewType) => { - track( - 'Switch View Type', - { view_type: newType, item_type: itemType }, - connectionInfoRef.current - ); - }, - [itemType, track, connectionInfoRef] - ); - - const [sortControls, sortState] = useSortControls(sortBy); - const [viewTypeControls, viewType] = useViewTypeControls({ - onChange: onViewTypeChange, - }); - const sortedItems = useSortedItems(items, sortState); - - const itemWidth = viewType === 'grid' ? itemGridWidth : itemListWidth; - const itemHeight = viewType === 'grid' ? itemGridHeight : itemListHeight; - - const shouldShowControls = items.length > 0; - - const renderItem: React.ComponentProps['renderItem'] = - useCallback( - ({ index, ...props }) => { - const item = sortedItems[index]; - return _renderItem({ - item, - viewType, - onItemClick, - onDeleteItemClick, - ...props, - }); - }, - [_renderItem, onDeleteItemClick, onItemClick, sortedItems, viewType] - ); - - return ( -
- - } - > - {(scrollTriggerRef) => { - return ( - { - return
; - }} - headerHeight={0} - itemKey={(index: number) => sortedItems[index]._id} - classNames={gridStyles} - resetActiveItemOnBlur={false} - data-testid={`${itemType}-grid`} - >
- ); - }} -
-
- ); -}; diff --git a/packages/databases-collections-list/src/items-table.tsx b/packages/databases-collections-list/src/items-table.tsx new file mode 100644 index 00000000000..1d59a3b37d4 --- /dev/null +++ b/packages/databases-collections-list/src/items-table.tsx @@ -0,0 +1,596 @@ +import React, { Fragment, useCallback, useMemo } from 'react'; +import type { + LeafyGreenTableCell, + LGColumnDef, + HeaderGroup, + GroupedItemAction, + LeafyGreenTableRow, + LeafyGreenVirtualItem, + LGTableDataType, + CellContext, + LeafyGreenVirtualTable, + LeafyGreenTable, +} from '@mongodb-js/compass-components'; +import { + css, + cx, + spacing, + WorkspaceContainer, + Button, + Icon, + Breadcrumbs, + Table, + TableHead, + TableBody, + useLeafyGreenVirtualTable, + HeaderRow, + HeaderCell, + flexRender, + ExpandedContent, + Row, + Cell, + ItemActionGroup, + useLeafyGreenTable, +} from '@mongodb-js/compass-components'; +import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; +import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; +import toNS from 'mongodb-ns'; +import { getConnectionTitle } from '@mongodb-js/connection-info'; +import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; +import { usePreferences } from 'compass-preferences-model/provider'; + +type Item = { + _id: string; + name: string; + inferred_from_privileges?: boolean; +}; + +export const createButtonStyles = css({ + whiteSpace: 'nowrap', +}); + +type ItemsTableProps = { + 'data-testid'?: string; + virtual?: boolean; + namespace?: string; + itemType: 'collection' | 'database'; + columns: LGColumnDef[]; + items: T[]; + onItemClick: (id: string) => void; + onDeleteItemClick?: (id: string) => void; + onCreateItemClick?: () => void; + onRefreshClick?: () => void; + renderLoadSampleDataBanner?: () => React.ReactNode; +}; + +const controlsContainerStyles = css({ + paddingTop: spacing[200], + paddingRight: spacing[400], + paddingBottom: spacing[400], + paddingLeft: spacing[400], + + display: 'grid', + gridTemplate: '1fr / 100%', + gap: spacing[200], +}); + +const controlRowStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[200], +}); + +const controlStyles = css({ + flex: 'none', +}); + +const breadcrumbContainerStyles = css({ + display: 'flex', + minWidth: 0, + paddingTop: spacing[200], + paddingBottom: spacing[200], +}); + +const pushRightStyles = css({ + marginLeft: 'auto', +}); + +const bannerRowStyles = css({ + paddingTop: spacing[200], +}); + +function buildChartsUrl( + groupId: string, + clusterName: string, + namespace?: string +) { + const { database } = toNS(namespace ?? ''); + const url = new URL(`/charts/${groupId}`, window.location.origin); + url.searchParams.set('sourceType', 'cluster'); + url.searchParams.set('name', clusterName); + if (database) { + url.searchParams.set('database', database); + } + return url.toString(); +} + +const TableControls: React.FunctionComponent<{ + namespace?: string; + itemType: string; + onCreateItemClick?: () => void; + onRefreshClick?: () => void; + renderLoadSampleDataBanner?: () => React.ReactNode; +}> = ({ + namespace, + itemType, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, +}) => { + const connectionInfo = useConnectionInfo(); + const connectionTitle = getConnectionTitle(connectionInfo); + const { + openDatabasesWorkspace, + openCollectionsWorkspace, + openShellWorkspace, + } = useOpenWorkspace(); + const track = useTelemetry(); + const { enableShell: showOpenShellButton } = usePreferences(['enableShell']); + + const breadcrumbs = useMemo(() => { + const { database } = toNS(namespace ?? ''); + const items = [ + { + name: connectionTitle, + onClick: () => { + openDatabasesWorkspace(connectionInfo.id); + }, + }, + ]; + + if (database) { + items.push({ + name: database, + onClick: () => { + openCollectionsWorkspace(connectionInfo.id, database); + }, + }); + } + + return items; + }, [ + connectionInfo.id, + connectionTitle, + namespace, + openCollectionsWorkspace, + openDatabasesWorkspace, + ]); + + const banner = renderLoadSampleDataBanner?.(); + + return ( +
+
+
+ +
+ +
+ {showOpenShellButton && ( + + )} + + {connectionInfo.atlasMetadata && ( + + )} + + {onCreateItemClick && ( +
+ +
+ )} + + {onRefreshClick && ( +
+ +
+ )} +
+
+ {banner &&
{banner}
} +
+ ); +}; + +const itemsTableContainerStyles = css({ + width: '100%', + height: '100%', +}); + +const virtualScrollingContainerHeight = css({ + width: '100%', + height: '100%', + padding: `0 ${spacing[400]}px`, +}); + +const actionsCellClassName = 'item-actions-cell'; + +// When row is hovered, we show the delete button +const rowStyles = css({ + ':hover': { + [`.${actionsCellClassName}`]: { + button: { + opacity: 1, + }, + }, + }, +}); + +// When row is not hovered, we hide the delete button +const actionsCellStyles = css({ + button: { + opacity: 0, + '&:focus': { + opacity: 1, + }, + }, + minWidth: spacing[800], +}); + +type ItemAction = 'delete'; + +// Helper: Build actions array based on item state +const buildItemActions = ( + item: Item, + { + readOnly, + hasDeleteHandler, + }: { readOnly: boolean; hasDeleteHandler: boolean } +): GroupedItemAction[] => { + const actions: GroupedItemAction[] = []; + if (!readOnly && hasDeleteHandler && !item.inferred_from_privileges) { + actions.push({ + action: 'delete', + label: `Delete ${item.name}`, + tooltip: `Delete ${item.name}`, + icon: 'Trash', + }); + } + + return actions; +}; + +type ItemActionsProps = { + item: Item; + onDeleteItemClick?: (name: string) => void; +}; + +const ItemActions: React.FunctionComponent = ({ + item, + onDeleteItemClick, +}) => { + const { readOnly } = usePreferences(['readOnly']); + const itemActions = useMemo( + () => + buildItemActions(item, { + readOnly, + hasDeleteHandler: !!onDeleteItemClick, + }), + [item, onDeleteItemClick, readOnly] + ); + + const onAction = useCallback( + (action: ItemAction) => { + if (action === 'delete') { + onDeleteItemClick?.(item._id); + } + }, + [item, onDeleteItemClick] + ); + + return ( + + data-testid="item-actions" + actions={itemActions} + onAction={onAction} + /> + ); +}; + +function calculateColumnsWithActions( + columns: LGColumnDef[], + onDeleteItemClick: ItemsTableProps['onDeleteItemClick'] +) { + if (onDeleteItemClick) { + return [ + ...columns, + { + id: 'actions', + header: '', + maxSize: 40, + cell: (info: CellContext, unknown>) => { + return ( + + ); + }, + }, + ]; + } + return columns; +} + +type RowItem = { + row: LeafyGreenTableRow; + virtualRow?: LeafyGreenVirtualItem; +}; + +const ItemsTableInner = ({ + 'data-testid': dataTestId, + namespace, + itemType, + items, + onItemClick, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, + tableContainerRef, + table, + rowItems, +}: { + 'data-testid'?: string; + namespace?: string; + itemType: 'collection' | 'database'; + items: T[]; + onItemClick: (id: string) => void; + onCreateItemClick?: () => void; + onRefreshClick?: () => void; + renderLoadSampleDataBanner?: () => React.ReactNode; + tableContainerRef?: React.RefObject; + table: LeafyGreenTable | LeafyGreenVirtualTable; + rowItems: RowItem[]; +}): React.ReactElement => { + return ( +
+ + } + > + + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {rowItems.map(({ row, virtualRow }) => { + const isExpandedContent = row.isExpandedContent ?? false; + + return ( + + {!isExpandedContent && ( + onItemClick(row.original._id)} + > + {row + .getVisibleCells() + .map((cell: LeafyGreenTableCell) => { + const isActionsCell = cell.column.id === 'actions'; + + return ( + // cell is required + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )} + {isExpandedContent && } + + ); + })} + +
+
+
+ ); +}; + +function mapVirtualRowItems( + table: LeafyGreenVirtualTable +): RowItem[] { + const virtualItems = table.virtual.getVirtualItems(); + return virtualItems.map((virtualItem) => { + return { + row: virtualItem.row, + virtualRow: virtualItem, + }; + }); +} + +export const VirtualItemsTable = ({ + 'data-testid': dataTestId, + namespace, + itemType, + columns, + items, + onItemClick, + onDeleteItemClick, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, +}: ItemsTableProps): React.ReactElement => { + const tableContainerRef = React.useRef(null); + + const columnsWithActions = useMemo(() => { + return calculateColumnsWithActions(columns, onDeleteItemClick); + }, [columns, onDeleteItemClick]); + + const table = useLeafyGreenVirtualTable({ + containerRef: tableContainerRef, + data: items, + columns: columnsWithActions, + virtualizerOptions: { + estimateSize: () => 40, + overscan: 10, + }, + }); + + const rowItems = mapVirtualRowItems(table); + + return ( + + data-testid={dataTestId} + namespace={namespace} + itemType={itemType} + items={items} + onItemClick={onItemClick} + onCreateItemClick={onCreateItemClick} + onRefreshClick={onRefreshClick} + renderLoadSampleDataBanner={renderLoadSampleDataBanner} + tableContainerRef={tableContainerRef} + table={table} + rowItems={rowItems} + > + ); +}; + +function mapRowItems(table: LeafyGreenTable): RowItem[] { + const rows = table.getRowModel().rows; + return rows.map((row) => { + return { + row, + virtualRow: undefined, + }; + }); +} + +export const ItemsTable = ({ + 'data-testid': dataTestId, + namespace, + itemType, + columns, + items, + onItemClick, + onDeleteItemClick, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, +}: ItemsTableProps): React.ReactElement => { + const columnsWithActions = useMemo(() => { + return calculateColumnsWithActions(columns, onDeleteItemClick); + }, [columns, onDeleteItemClick]); + + const table = useLeafyGreenTable({ + data: items, + columns: columnsWithActions, + }); + + const rowItems = mapRowItems(table); + + return ( + + data-testid={dataTestId} + namespace={namespace} + itemType={itemType} + items={items} + onItemClick={onItemClick} + onCreateItemClick={onCreateItemClick} + onRefreshClick={onRefreshClick} + renderLoadSampleDataBanner={renderLoadSampleDataBanner} + table={table} + rowItems={rowItems} + > + ); +}; diff --git a/packages/databases-collections-list/src/namespace-card.tsx b/packages/databases-collections-list/src/namespace-card.tsx deleted file mode 100644 index f2be5888adc..00000000000 --- a/packages/databases-collections-list/src/namespace-card.tsx +++ /dev/null @@ -1,359 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { useCallback, useMemo } from 'react'; -import { - Card, - css, - Icon, - spacing, - Subtitle, - useHoverState, - Badge, - Tooltip, - cx, - useFocusState, - FocusState, - palette, - mergeProps, - useDefaultAction, - ItemActionControls, - useDarkMode, -} from '@mongodb-js/compass-components'; -import type { - BadgeVariant, - GlyphName, - ItemAction, - SignalPopover, -} from '@mongodb-js/compass-components'; -import { NamespaceParam } from './namespace-param'; -import type { ViewType } from './use-view-type'; -import { usePreferences } from 'compass-preferences-model/provider'; - -const cardTitleGroup = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[400], -}); - -const CardTitleGroup: React.FunctionComponent = ({ children }) => { - return
{children}
; -}; - -const inferredFromPrivilegesLightStyles = css({ - color: palette.gray.dark1, -}); - -const inferredFromPrivilegesDarkStyles = css({ - color: palette.gray.base, -}); - -const inactiveCardStyles = css({ - borderStyle: 'dashed', - borderWidth: spacing[50], - '&:hover': { - borderStyle: 'dashed', - borderWidth: spacing[50], - }, -}); - -const tooltipTriggerStyles = css({ - display: 'flex', -}); - -const cardNameWrapper = css({ - // Workaround for uncollapsible text in flex children - minWidth: 0, -}); - -const cardNameDark = css({ - color: palette.green.light2, -}); - -const cardNameLight = css({ - color: palette.green.dark2, -}); - -const cardName = css({ - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - // To make container 28px to match leafygreen buttons - paddingTop: 2, - paddingBottom: 2, - // TS is very confused if fontWeight is not a number even though it's a valid - // CSS value - fontWeight: '600 !important' as unknown as number, -}); - -const CardName: React.FunctionComponent<{ - children: string; - inferredFromPrivileges: boolean; -}> = ({ children, inferredFromPrivileges }) => { - const darkMode = useDarkMode(); - return ( -
- - {children} - -
- ); -}; - -const cardActionContainer = css({ - marginLeft: 'auto', - flex: 'none', -}); - -const cardBadges = css({ - display: 'flex', - gap: spacing[200], - // Preserving space for when cards with and without badges are mixed in a - // single row - minHeight: 20, -}); - -const CardBadges: React.FunctionComponent = ({ children }) => { - return
{children}
; -}; - -const cardBadge = css({ - gap: spacing[100], -}); - -const cardBadgeLabel = css({}); - -export type BadgeProp = { - id: string; - name: string; - variant?: BadgeVariant; - icon?: GlyphName; - hint?: React.ReactNode; -}; - -const CardBadge: React.FunctionComponent = ({ - id, - name, - icon, - variant, - hint, -}) => { - const badge = useCallback( - ({ className, children, ...props } = {}) => { - return ( - - {icon && } - {name} - {/* Tooltip will be rendered here */} - {children} - - ); - }, - [id, icon, name, variant] - ); - - if (hint) { - return {hint}; - } - - return badge(); -}; - -const card = css({ - padding: spacing[400], -}); - -export type DataProp = { - label: React.ReactNode; - value: React.ReactNode; - hint?: React.ReactNode; - insights?: React.ComponentProps['signals']; -}; - -export type NamespaceItemCardProps = { - id: string; - type: 'database' | 'collection'; - viewType: ViewType; - name: string; - status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; - data: DataProp[]; - badges?: BadgeProp[] | null; - inferredFromPrivileges: boolean; - onItemClick: (id: string) => void; - onItemDeleteClick?: (id: string) => void; -}; - -const namespaceDataGroup = css({ - display: 'flex', - gap: spacing[200], - marginTop: spacing[400], -}); - -const column = css({ - flexDirection: 'column', -}); - -type NamespaceAction = 'delete'; - -export const NamespaceItemCard: React.FunctionComponent< - NamespaceItemCardProps & - Omit< - React.HTMLProps, - Extract - > -> = ({ - id, - type, - name, - status, - data, - onItemClick, - onItemDeleteClick, - badges = null, - viewType, - inferredFromPrivileges, - ...props -}) => { - const { readOnly, enableDbAndCollStats } = usePreferences([ - 'readOnly', - 'enableDbAndCollStats', - ]); - const darkMode = useDarkMode(); - const [hoverProps, isHovered] = useHoverState(); - const [focusProps, focusState] = useFocusState(); - - const onDefaultAction = useCallback(() => { - onItemClick(id); - }, [onItemClick, id]); - - const hasDeleteHandler = !!onItemDeleteClick; - const cardActions: ItemAction[] = useMemo(() => { - return readOnly || !hasDeleteHandler || inferredFromPrivileges - ? [] - : [ - { - action: 'delete', - label: `Delete ${type}`, - icon: 'Trash', - }, - ]; - }, [type, readOnly, inferredFromPrivileges, hasDeleteHandler]); - - const defaultActionProps = useDefaultAction(onDefaultAction); - - const onAction = useCallback( - (action: NamespaceAction) => { - if (action === 'delete') { - onItemDeleteClick?.(id); - } - }, - [onItemDeleteClick, id] - ); - - const badgesGroup = badges && ( - - {badges.map((badge) => { - return ; - })} - - ); - - const cardProps = mergeProps( - { - className: cx( - card, - inferredFromPrivileges && [ - !darkMode && inferredFromPrivilegesLightStyles, - darkMode && inferredFromPrivilegesDarkStyles, - inactiveCardStyles, - ] - ), - }, - defaultActionProps, - hoverProps, - focusProps, - props - ); - - const isButtonVisible = - [FocusState.FocusVisible, FocusState.FocusWithinVisible].includes( - focusState - ) || isHovered; - - return ( - // @ts-expect-error the error here is caused by passing children to Card - // component, even though it's allowed on the implementation level the types - // are super confused and don't allow that - - - - {name} - - - {inferredFromPrivileges && ( - - - - } - > - Your privileges grant you access to this namespace, but it might not - currently exist - - )} - - {viewType === 'list' && badgesGroup} - - 0} - actions={cardActions} - onAction={onAction} - className={cardActionContainer} - > - - - {viewType === 'grid' && badgesGroup} - - {enableDbAndCollStats && ( -
- {data.map(({ label, value, hint, insights }, idx) => { - return ( - - ); - })} -
- )} -
- ); -}; diff --git a/packages/databases-collections-list/src/namespace-param.tsx b/packages/databases-collections-list/src/namespace-param.tsx deleted file mode 100644 index fbbeaf188f6..00000000000 --- a/packages/databases-collections-list/src/namespace-param.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { useMemo } from 'react'; -import { - InlineDefinition, - spacing, - css, - cx, - ContentWithFallback, - Placeholder, - keyframes, - SignalPopover, -} from '@mongodb-js/compass-components'; -import type { ViewType } from './use-view-type'; -import { usePreference } from 'compass-preferences-model/provider'; - -const namespaceParam = css({ - display: 'flex', - gap: '1ch', - flex: 1, - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - minWidth: 0, - maxWidth: spacing[1600] * 4, -}); - -const multiline = css({ - display: 'flex', - flexDirection: 'column', - gap: 0, -}); - -const namespaceParamLabel = css({ - fontWeight: 'bold', -}); - -const namespaceParamValueContainer = css({ - position: 'relative', - width: '100%', - // Keeping container height for the placeholder to appear - minHeight: 20, -}); - -const namespaceParamValueContainerWithInsights = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[200], -}); - -const namespaceParamValue = css({ - opacity: 1, - transition: 'opacity .16s linear', -}); - -const namespaceParamValueRefreshing = css({ - opacity: 0.3, -}); - -const namespaceParamValueMissing = css({ - opacity: 0.3, -}); - -const namespaceParamValuePlaceholder = css({ - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - right: 0, - display: 'flex', - opacity: 0, - transition: 'opacity .16s ease-out', -}); - -const visible = css({ - opacity: 1, - transitionTimingFunction: 'ease-in', -}); - -const fadeInAnimation = keyframes({ - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, -}); - -const fadeIn = css({ - animation: `${fadeInAnimation} .16s ease-out`, -}); - -export const NamespaceParam: React.FunctionComponent<{ - label: React.ReactNode; - value: React.ReactNode; - status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; - hint?: React.ReactNode; - viewType: ViewType; - insights?: React.ComponentProps['signals']; -}> = ({ label, value, status, hint, viewType, insights }) => { - const showInsights = usePreference('showInsights'); - - const renderedValue = useMemo(() => { - const isReady = status !== 'initial' && status !== 'fetching'; - return ( - { - if (!shouldRender) { - return null; - } - - // eslint-disable-next-line eqeqeq - const missingValue = value == null || status === 'error'; - - return ( - - {missingValue ? '—' : value} - - ); - }} - fallback={(shouldRender) => ( - - - - )} - > - ); - }, [value, status]); - - return ( -
- - {hint ? ( - - {label}: - - ) : ( - <>{label}: - )} - - - {renderedValue} - {showInsights && insights && ( - - )} - -
- ); -}; diff --git a/packages/databases-collections-list/src/use-view-type.tsx b/packages/databases-collections-list/src/use-view-type.tsx deleted file mode 100644 index 765e9c7e9af..00000000000 --- a/packages/databases-collections-list/src/use-view-type.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - SegmentedControl, - SegmentedControlOption, - Icon, - Label, - css, - spacing, - useId, -} from '@mongodb-js/compass-components'; - -export type ViewType = 'grid' | 'list'; - -const VIEW_TYPE_SETTINGS_KEY = 'compass_items_grid_view_type'; - -function getViewTypeSettingsFromSessionStorage( - defaultType: ViewType = 'grid' -): ViewType { - try { - return ( - (window.sessionStorage.getItem(VIEW_TYPE_SETTINGS_KEY) as ViewType) ?? - defaultType - ); - } catch { - return defaultType; - } -} - -function setViewTypeSettingsFromSessionStorage(val: ViewType) { - try { - window.sessionStorage.setItem(VIEW_TYPE_SETTINGS_KEY, val); - } catch { - // noop - } -} - -const controlsContainer = css({ - display: 'flex', - alignItems: 'center', - gap: spacing[200], -}); - -const label = css({ - // Because leafygreen - margin: '0 !important', - padding: '0 !important', -}); - -export function useViewTypeControls({ - defaultViewType = 'list', - onChange = () => { - // noop - }, -}: { - defaultViewType?: ViewType; - onChange?: (newType: ViewType) => void; -}): [React.ReactElement, ViewType] { - const [viewType, setViewType] = useState(() => - getViewTypeSettingsFromSessionStorage(defaultViewType) - ); - useEffect(() => { - setViewTypeSettingsFromSessionStorage(viewType); - }, [viewType]); - const onViewTypeChange = useCallback( - (val: ViewType) => { - onChange(val); - setViewType(val); - }, - [onChange] - ); - const labelId = useId(); - const controlId = useId(); - const viewControls = useMemo(() => { - return ( -
- - void} - > - } - /> - } - /> - -
- ); - }, [labelId, controlId, viewType, onViewTypeChange]); - return [viewControls, viewType]; -} diff --git a/packages/databases-collections-list/test/utils.ts b/packages/databases-collections-list/test/utils.ts new file mode 100644 index 00000000000..cc20fc56fc7 --- /dev/null +++ b/packages/databases-collections-list/test/utils.ts @@ -0,0 +1,56 @@ +import { + userEvent, + waitFor, + type screen, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; + +export function inspectTable(_screen: typeof screen, dataTestId: string) { + const list = _screen.getByTestId(dataTestId); + const ths = list.querySelectorAll('[data-lgid="lg-table-header"]'); + const trs = list.querySelectorAll('[data-lgid="lg-table-row"]'); + const table = Array.from(trs).map((tr) => + Array.from(tr.querySelectorAll('td')).map((td) => td.textContent) + ); + + const columns = Array.from(ths).map((el) => el.textContent); + + const getColumn = (columnName: string) => { + const columnIndex = columns.indexOf(columnName); + return table.map((row) => row[columnIndex]); + }; + + return { list, ths, trs, table, columns, getColumn }; +} + +export async function testSortColumn( + _screen: typeof screen, + listId: string, + columnName: string, + expectedOrders: string[][] +) { + // initial order + let result = inspectTable(_screen, listId); + expect(result.getColumn(columnName)).to.deep.equal(expectedOrders[0]); + + // descending for numerical columns, ascending for text + userEvent.click(_screen.getByLabelText(`Sort by ${columnName}`)); + await waitFor(function () { + result = inspectTable(_screen, listId); + expect(result.getColumn(columnName)).to.deep.equal(expectedOrders[1]); + }); + + // ascending for numerical columns, descending for text + userEvent.click(_screen.getByLabelText(`Sort by ${columnName}`)); + await waitFor(function () { + result = inspectTable(_screen, listId); + expect(result.getColumn(columnName)).to.deep.equal(expectedOrders[2]); + }); + + // back to initial order + userEvent.click(_screen.getByLabelText(`Sort by ${columnName}`)); + await waitFor(function () { + result = inspectTable(_screen, listId); + expect(result.getColumn(columnName)).to.deep.equal(expectedOrders[0]); + }); +} diff --git a/packages/databases-collections/src/collections-plugin.spec.tsx b/packages/databases-collections/src/collections-plugin.spec.tsx index ce4eb6bc1f9..a5564cb9a7f 100644 --- a/packages/databases-collections/src/collections-plugin.spec.tsx +++ b/packages/databases-collections/src/collections-plugin.spec.tsx @@ -60,6 +60,10 @@ describe('Collections [Plugin]', function () { }); describe('with loaded collections', function () { + before(() => { + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + }); + beforeEach(async function () { const Plugin = CollectionsWorkspaceTab.provider.withMockServices({ instance: mongodbInstance, @@ -75,13 +79,16 @@ describe('Collections [Plugin]', function () { appRegistry = Sinon.spy(globalAppRegistry); await waitFor(() => { - expect(screen.getByRole('gridcell', { name: /bar/ })).to.exist; - expect(screen.getByRole('gridcell', { name: /buz/ })).to.exist; + expect(screen.getByTestId('collections-list-row-bar')).to.exist; + expect(screen.getByTestId('collections-list-row-buz')).to.exist; }); }); it('renders a list of collections', function () { - expect(screen.getAllByRole('gridcell')).to.have.lengthOf(2); + const list = screen.getByTestId('collections-list'); + expect( + list.querySelectorAll('[data-lgid="lg-table-row"]') + ).to.have.lengthOf(2); }); it('initiates action to create a collection', function () { @@ -105,8 +112,11 @@ describe('Collections [Plugin]', function () { }); it('initiates action to drop a collection', function () { - userEvent.hover(screen.getByRole('gridcell', { name: /bar/ })); - userEvent.click(screen.getByRole('button', { name: /Delete/ })); + const row = screen.getByTestId('collections-list-row-bar'); + userEvent.hover(row); + userEvent.click( + row.querySelector('[aria-label="Delete bar"]') as Element + ); expect(appRegistry.emit).to.have.been.calledWithMatch( 'open-drop-collection', { ns: 'foo.bar' }, @@ -122,8 +132,8 @@ describe('Collections [Plugin]', function () { }); await waitFor(() => { - expect(screen.queryByRole('gridcell', { name: /bar/ })).to.not.exist; - expect(screen.getByRole('gridcell', { name: /testdb/ })).to.exist; + expect(screen.queryByTestId('collections-list-row-bar')).to.not.exist; + expect(screen.getByTestId('collections-list-row-testdb')).to.exist; }); expect(screen.getByRole('button', { name: /Create collection/ })).to diff --git a/packages/databases-collections/src/databases-plugin.spec.tsx b/packages/databases-collections/src/databases-plugin.spec.tsx index 32fcf190833..c6df577b3ca 100644 --- a/packages/databases-collections/src/databases-plugin.spec.tsx +++ b/packages/databases-collections/src/databases-plugin.spec.tsx @@ -30,6 +30,10 @@ describe('Databasees [Plugin]', function () { }); describe('with loaded databases', function () { + before(() => { + process.env.COMPASS_DISABLE_VIRTUAL_TABLE_RENDERING = 'true'; + }); + beforeEach(async function () { preferences = await createSandboxFromDefaultPreferences(); mongodbInstance = Sinon.spy( @@ -63,13 +67,16 @@ describe('Databasees [Plugin]', function () { appRegistry = Sinon.spy(globalAppRegistry); await waitFor(() => { - expect(screen.getByRole('gridcell', { name: /foo/ })).to.exist; - expect(screen.getByRole('gridcell', { name: /bar/ })).to.exist; + expect(screen.getByTestId('databases-list-row-foo')).to.exist; + expect(screen.getByTestId('databases-list-row-bar')).to.exist; }); }); it('renders a list of databases', function () { - expect(screen.getAllByRole('gridcell')).to.have.lengthOf(2); + const list = screen.getByTestId('databases-list'); + expect( + list.querySelectorAll('[data-lgid="lg-table-row"]') + ).to.have.lengthOf(2); }); it('initiates action to create a database', function () { @@ -90,8 +97,11 @@ describe('Databasees [Plugin]', function () { }); it('initiates action to delete a database', function () { - userEvent.hover(screen.getByRole('gridcell', { name: /foo/ })); - userEvent.click(screen.getByRole('button', { name: /Delete/ })); + const row = screen.getByTestId('databases-list-row-foo'); + userEvent.hover(row); + userEvent.click( + row.querySelector('[aria-label="Delete foo"]') as Element + ); expect(appRegistry.emit).to.have.been.calledWith( 'open-drop-database', 'foo', @@ -107,8 +117,8 @@ describe('Databasees [Plugin]', function () { }); await waitFor(() => { - expect(screen.queryByRole('gridcell', { name: /foo/ })).to.not.exist; - expect(screen.getByRole('gridcell', { name: /testdb/ })).to.exist; + expect(screen.queryByTestId('databases-list-row-foo')).to.not.exist; + expect(screen.getByTestId('databases-list-row-testdb')).to.exist; }); expect(screen.getByRole('button', { name: /Create database/ })).to.exist;