diff --git a/package-lock.json b/package-lock.json index d68c0c7b84a..84b82997072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "configs/*", "scripts" ], + "dependencies": { + "@leafygreen-ui/avatar": "^3.1.0" + }, "devDependencies": { "@mongodb-js/monorepo-tools": "^1.1.16", "@mongodb-js/sbom-tools": "^0.7.2", diff --git a/package.json b/package.json index a350728db4d..92d14e66418 100644 --- a/package.json +++ b/package.json @@ -132,5 +132,8 @@ "@leafygreen-ui/text-area": "^10.0.2", "@leafygreen-ui/card": "^12.0.2", "@leafygreen-ui/logo": "^10.0.2" + }, + "dependencies": { + "@leafygreen-ui/avatar": "^3.1.0" } } diff --git a/packages/atlas-service/src/atlas-service.ts b/packages/atlas-service/src/atlas-service.ts index b3d29318879..5a30350befd 100644 --- a/packages/atlas-service/src/atlas-service.ts +++ b/packages/atlas-service/src/atlas-service.ts @@ -78,6 +78,16 @@ export class AtlasService { // https://github.com/10gen/mms/blob/9f858bb987aac6aa80acfb86492dd74c89cbb862/client/packages/project/common/ajaxPrefilter.ts#L34-L49 return this.cloudEndpoint(path); } + tempEndpoint(path?: string): string { + return `https://cluster-connection.cloud-dev.mongodb.com${normalizePath( + path + )}`; + } + userDataEndpoint(path?: string): string { + return `https://cluster-connection.cloud-dev.mongodb.com/userData${normalizePath( + path + )}`; + } driverProxyEndpoint(path?: string): string { return `${this.config.ccsBaseUrl}${normalizePath(path)}`; } @@ -91,13 +101,14 @@ export class AtlasService { { url } ); try { + const headers = { + ...this.options?.defaultHeaders, + ...(shouldAddCSRFHeaders(init?.method) && getCSRFHeaders()), + ...init?.headers, + }; const res = await fetch(url, { ...init, - headers: { - ...this.options?.defaultHeaders, - ...(shouldAddCSRFHeaders(init?.method) && getCSRFHeaders()), - ...init?.headers, - }, + headers, }); this.logger.log.info( this.logger.mongoLogId(1_001_000_309), @@ -128,6 +139,7 @@ export class AtlasService { const authHeaders = await this.authService.getAuthHeaders(); return this.fetch(url, { ...init, + credentials: 'include', headers: { ...init?.headers, ...authHeaders, diff --git a/packages/atlas-service/src/provider.tsx b/packages/atlas-service/src/provider.tsx index c4786939a91..bbfe0f14c1a 100644 --- a/packages/atlas-service/src/provider.tsx +++ b/packages/atlas-service/src/provider.tsx @@ -48,7 +48,7 @@ export const AtlasServiceProvider: React.FC<{ ); }); -function useAtlasServiceContext(): AtlasService { +export function useAtlasServiceContext(): AtlasService { const service = useContext(AtlasServiceContext); if (!service) { throw new Error('No AtlasService available in this context'); diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/index.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/index.tsx index fdc36150d87..62822a14162 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/index.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import { usePreference } from 'compass-preferences-model/provider'; import { Button, Icon, css, spacing } from '@mongodb-js/compass-components'; import { exportToLanguage } from '../../../modules/export-to-language'; import { SaveMenu } from './pipeline-menus'; @@ -49,7 +50,10 @@ export const PipelineSettings: React.FunctionComponent< }) => { // TODO: remove direct check for storage existing, breaks single source of // truth rule and exposes services to UI, this breaks the rules for locators - const enableSavedAggregationsQueries = !!usePipelineStorage(); + const pipelineStorageAvailable = !!usePipelineStorage(); + const isMyQueriesEnabled = usePreference('enableMyQueries'); + const enableSavedAggregationsQueries = + pipelineStorageAvailable && isMyQueriesEnabled; const isPipelineNameDisplayed = !editViewName && !!enableSavedAggregationsQueries; diff --git a/packages/compass-data-modeling/src/services/data-model-storage-electron.tsx b/packages/compass-data-modeling/src/services/data-model-storage-electron.tsx index f820943228f..e00d60d2616 100644 --- a/packages/compass-data-modeling/src/services/data-model-storage-electron.tsx +++ b/packages/compass-data-modeling/src/services/data-model-storage-electron.tsx @@ -11,6 +11,7 @@ class DataModelStorageElectron implements DataModelStorage { private readonly userData: FileUserData< typeof MongoDBDataModelDescriptionSchema >; + constructor(basePath?: string) { this.userData = new FileUserData( MongoDBDataModelDescriptionSchema, @@ -20,12 +21,15 @@ class DataModelStorageElectron implements DataModelStorage { } ); } + save(description: MongoDBDataModelDescription) { return this.userData.write(description.id, description); } + delete(id: MongoDBDataModelDescription['id']) { return this.userData.delete(id); } + async loadAll(): Promise { try { const res = await this.userData.readAll(); @@ -34,6 +38,7 @@ class DataModelStorageElectron implements DataModelStorage { return []; } } + async load(id: string): Promise { return ( (await this.loadAll()).find((item) => { diff --git a/packages/compass-preferences-model/src/preferences-schema.tsx b/packages/compass-preferences-model/src/preferences-schema.tsx index 0ee39436d04..6b8ead5b276 100644 --- a/packages/compass-preferences-model/src/preferences-schema.tsx +++ b/packages/compass-preferences-model/src/preferences-schema.tsx @@ -92,6 +92,7 @@ export type UserConfigurablePreferences = PermanentFeatureFlags & enableExplainPlan: boolean; enableAtlasSearchIndexes: boolean; enableImportExport: boolean; + enableMyQueries: boolean; enableAggregationBuilderRunPipeline: boolean; enableAggregationBuilderExtraOptions: boolean; enableGenAISampleDocumentPassing: boolean; @@ -1019,6 +1020,17 @@ export const storedUserPreferencesProps: Required<{ validator: z.boolean().default(true), type: 'boolean', }, + enableMyQueries: { + ui: true, + cli: true, + global: true, + description: { + short: + 'Enable My Queries feature to save and manage favorite queries and aggregations', + }, + validator: z.boolean().default(true), + type: 'boolean', + }, inferNamespacesFromPrivileges: { ui: true, diff --git a/packages/compass-query-bar/src/components/query-bar.tsx b/packages/compass-query-bar/src/components/query-bar.tsx index 0b067ff8be9..585d1af6bca 100644 --- a/packages/compass-query-bar/src/components/query-bar.tsx +++ b/packages/compass-query-bar/src/components/query-bar.tsx @@ -14,7 +14,10 @@ import { createAIPlaceholderHTMLPlaceholder, } from '@mongodb-js/compass-generative-ai'; import { connect } from '../stores/context'; -import { useIsAIFeatureEnabled } from 'compass-preferences-model/provider'; +import { + useIsAIFeatureEnabled, + usePreference, +} from 'compass-preferences-model/provider'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { @@ -202,8 +205,11 @@ export const QueryBar: React.FunctionComponent = ({ const favoriteQueryStorageAvailable = !!useFavoriteQueryStorageAccess(); const recentQueryStorageAvailable = !!useRecentQueryStorageAccess(); + const isMyQueriesEnabled = usePreference('enableMyQueries'); const enableSavedAggregationsQueries = - favoriteQueryStorageAvailable && recentQueryStorageAvailable; + favoriteQueryStorageAvailable && + recentQueryStorageAvailable && + isMyQueriesEnabled; return (
{ try { + // Check if My Queries feature is enabled + const isMyQueriesEnabled = preferences.getPreferences().enableMyQueries; + if (!isMyQueriesEnabled) { + // If feature is disabled, dispatch empty array + dispatch({ + type: QueryBarActions.RecentQueriesFetched, + recents: [], + }); + return; + } + const { queryBar: { namespace }, } = _getState(); @@ -286,7 +297,14 @@ export const fetchRecents = (): QueryBarThunkAction< }; export const fetchSavedQueries = (): QueryBarThunkAction => { - return (dispatch) => { + return (dispatch, _getState, { preferences }) => { + // Check if My Queries feature is enabled + const isMyQueriesEnabled = preferences.getPreferences().enableMyQueries; + if (!isMyQueriesEnabled) { + // If feature is disabled, don't fetch anything + return; + } + void dispatch(fetchRecents()); void dispatch(fetchFavorites()); }; @@ -303,9 +321,20 @@ export const fetchFavorites = (): QueryBarThunkAction< return async ( dispatch, _getState, - { favoriteQueryStorage, logger: { debug } } + { favoriteQueryStorage, logger: { debug }, preferences } ) => { try { + // Check if My Queries feature is enabled + const isMyQueriesEnabled = preferences.getPreferences().enableMyQueries; + if (!isMyQueriesEnabled) { + // If feature is disabled, dispatch empty array + dispatch({ + type: QueryBarActions.FavoriteQueriesFetched, + favorites: [], + }); + return; + } + const { queryBar: { namespace }, } = _getState(); @@ -356,10 +385,7 @@ export const saveRecentAsFavorite = ( }; // add it in the favorite - await favoriteQueryStorage?.updateAttributes( - favoriteQuery._id, - favoriteQuery - ); + await favoriteQueryStorage?.saveQuery(favoriteQuery, favoriteQuery._id); // update favorites void dispatch(fetchFavorites()); diff --git a/packages/compass-sidebar/src/components/multiple-connections/navigation/navigation.tsx b/packages/compass-sidebar/src/components/multiple-connections/navigation/navigation.tsx index 6d8ebcfa1bf..d9c34275db1 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/navigation/navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/navigation/navigation.tsx @@ -12,6 +12,7 @@ import { useWorkspacePlugins, } from '@mongodb-js/compass-workspaces/provider'; import { usePreference } from 'compass-preferences-model/provider'; +import { useMyQueriesFeature } from '@mongodb-js/compass-web'; import React from 'react'; const navigationItem = css({ @@ -102,9 +103,10 @@ export function Navigation({ const { openMyQueriesWorkspace, openDataModelingWorkspace } = useOpenWorkspace(); const isDataModelingEnabled = usePreference('enableDataModeling'); + const isMyQueriesEnabled = useMyQueriesFeature(); return (
- {hasWorkspacePlugin('My Queries') && ( + {hasWorkspacePlugin('My Queries') && isMyQueriesEnabled && ( { return new AtlasUserData(getTestSchema(validatorOpts), type, { orgId, @@ -404,12 +405,13 @@ describe('AtlasUserData', function () { expect(options.headers['Content-Type']).to.equal('application/json'); const body = JSON.parse(options.body as string); - expect(body.id).to.equal('test-id'); - expect(body.projectId).to.equal('test-proj'); expect(body.data).to.be.a('string'); expect(JSON.parse(body.data as string)).to.deep.equal({ name: 'VSCode' }); expect(body.createdAt).to.be.a('string'); expect(new Date(body.createdAt as string)).to.be.instanceOf(Date); + // id and projectId should not be in the body (they're in the URL path) + expect(body.id).to.be.undefined; + expect(body.projectId).to.be.undefined; }); it('returns false when authenticatedFetch throws an error', async function () { @@ -461,6 +463,7 @@ describe('AtlasUserData', function () { const [, options] = authenticatedFetchStub.firstCall.args; const body = JSON.parse(options.body as string); expect(body.data).to.equal('custom:{"name":"Custom"}'); + expect(body.createdAt).to.be.a('string'); }); }); @@ -767,9 +770,11 @@ describe('AtlasUserData', function () { await userData.updateAttributes('test-id', { name: 'Updated' }); const [, putOptions] = authenticatedFetchStub.secondCall.args; - expect(putOptions.body as string).to.equal( + const body = JSON.parse(putOptions.body as string); + expect(body.data).to.equal( 'custom:{"name":"Updated","hasDarkMode":true,"hasWebSupport":false}' ); + expect(body.createdAt).to.be.a('string'); }); }); @@ -777,7 +782,7 @@ describe('AtlasUserData', function () { it('constructs URL correctly for write operation', async function () { authenticatedFetchStub.resolves(mockResponse({})); getResourceUrlStub.resolves( - 'cluster-connection.cloud.mongodb.com/FavoriteQueries/custom-org/custom-proj' + 'cluster-connection.cloud.mongodb.com/favoriteQueries/custom-org/custom-proj/test-id' ); const userData = getAtlasUserData({}, 'custom-org', 'custom-proj'); @@ -785,7 +790,7 @@ describe('AtlasUserData', function () { const [url] = authenticatedFetchStub.firstCall.args; expect(url).to.equal( - 'cluster-connection.cloud.mongodb.com/FavoriteQueries/custom-org/custom-proj' + 'cluster-connection.cloud.mongodb.com/favoriteQueries/custom-org/custom-proj/test-id' ); }); diff --git a/packages/compass-user-data/src/user-data.ts b/packages/compass-user-data/src/user-data.ts index 5db85695f60..cd61e5ebd8e 100644 --- a/packages/compass-user-data/src/user-data.ts +++ b/packages/compass-user-data/src/user-data.ts @@ -45,6 +45,7 @@ export abstract class IUserData { protected readonly dataType: string; protected readonly serialize: SerializeContent>; protected readonly deserialize: DeserializeContent; + constructor( validator: T, dataType: string, @@ -63,8 +64,16 @@ export abstract class IUserData { } abstract write(id: string, content: z.input): Promise; + abstract delete(id: string): Promise; + abstract readAll(options?: ReadOptions): Promise>; + + abstract readOne( + id: string, + options?: ReadOptions + ): Promise | undefined>; + abstract updateAttributes( id: string, data: Partial> @@ -72,8 +81,8 @@ export abstract class IUserData { } export class FileUserData extends IUserData { - private readonly basePath?: string; protected readonly semaphore = new Semaphore(100); + private readonly basePath?: string; constructor( validator: T, @@ -84,79 +93,9 @@ export class FileUserData extends IUserData { this.basePath = basePath; } - private getFileName(id: string) { - return `${id}.json`; - } - - private async getEnsuredBasePath(): Promise { - const basepath = this.basePath ? this.basePath : getStoragePath(); - - const root = path.join(basepath, this.dataType); - - await fs.mkdir(root, { recursive: true }); - - return root; - } - - private async getFileAbsolutePath(filepath?: string): Promise { - const root = await this.getEnsuredBasePath(); - const pathRelativeToRoot = path.relative( - root, - path.join(root, filepath ?? '') - ); - - if ( - pathRelativeToRoot.startsWith('..') || - path.isAbsolute(pathRelativeToRoot) - ) { - throw new Error( - `Invalid file path: '${filepath}' is not a subpath of ${root}.` - ); - } - - return path.resolve(root, pathRelativeToRoot); - } - - private async readAndParseFile( - absolutePath: string, - options: ReadOptions - ): Promise | undefined> { - let data: string; - let release: (() => void) | undefined = undefined; - try { - release = await this.semaphore.waitForRelease(); - data = await fs.readFile(absolutePath, 'utf-8'); - } catch (error) { - log.error(mongoLogId(1_001_000_234), 'Filesystem', 'Error reading file', { - path: absolutePath, - error: (error as Error).message, - }); - if (options.ignoreErrors) { - return undefined; - } - throw error; - } finally { - release?.(); - } - - try { - const content = this.deserialize(data); - return this.validator.parse(content); - } catch (error) { - log.error(mongoLogId(1_001_000_235), 'Filesystem', 'Error parsing data', { - path: absolutePath, - error: (error as Error).message, - }); - if (options.ignoreErrors) { - return undefined; - } - throw error; - } - } - async write(id: string, content: z.input) { // Validate the input. Here we are not saving the parsed content - // because after reading we validate the data again and it parses + // because after reading we validate the data again, and it parses // the read content back to the expected output. This way we ensure // that we exactly save what we want without transforming it. this.validator.parse(content); @@ -234,14 +173,17 @@ export class FileUserData extends IUserData { id: string, options?: { ignoreErrors: false } ): Promise>; + async readOne( id: string, options?: { ignoreErrors: true } ): Promise | undefined>; + async readOne( id: string, options?: ReadOptions ): Promise | undefined>; + async readOne( id: string, options: ReadOptions = { @@ -267,6 +209,76 @@ export class FileUserData extends IUserData { return false; } } + + private getFileName(id: string) { + return `${id}.json`; + } + + private async getEnsuredBasePath(): Promise { + const basepath = this.basePath ? this.basePath : getStoragePath(); + + const root = path.join(basepath, this.dataType); + + await fs.mkdir(root, { recursive: true }); + + return root; + } + + private async getFileAbsolutePath(filepath?: string): Promise { + const root = await this.getEnsuredBasePath(); + const pathRelativeToRoot = path.relative( + root, + path.join(root, filepath ?? '') + ); + + if ( + pathRelativeToRoot.startsWith('..') || + path.isAbsolute(pathRelativeToRoot) + ) { + throw new Error( + `Invalid file path: '${filepath}' is not a subpath of ${root}.` + ); + } + + return path.resolve(root, pathRelativeToRoot); + } + + private async readAndParseFile( + absolutePath: string, + options: ReadOptions + ): Promise | undefined> { + let data: string; + let release: (() => void) | undefined = undefined; + try { + release = await this.semaphore.waitForRelease(); + data = await fs.readFile(absolutePath, 'utf-8'); + } catch (error) { + log.error(mongoLogId(1_001_000_234), 'Filesystem', 'Error reading file', { + path: absolutePath, + error: (error as Error).message, + }); + if (options.ignoreErrors) { + return undefined; + } + throw error; + } finally { + release?.(); + } + + try { + const content = this.deserialize(data); + return this.validator.parse(content); + } catch (error) { + log.error(mongoLogId(1_001_000_235), 'Filesystem', 'Error parsing data', { + path: absolutePath, + error: (error as Error).message, + }); + if (options.ignoreErrors) { + return undefined; + } + throw error; + } + } } // TODO: update endpoints to reflect the merged api endpoints https://jira.mongodb.org/browse/CLOUDP-329716 @@ -275,6 +287,7 @@ export class AtlasUserData extends IUserData { private readonly getResourceUrl; private orgId: string; private projectId: string; + constructor( validator: T, dataType: string, @@ -296,7 +309,7 @@ export class AtlasUserData extends IUserData { async write(id: string, content: z.input): Promise { const url = await this.getResourceUrl( - `${this.dataType}/${this.orgId}/${this.projectId}` + `${this.dataType}/${this.orgId}/${this.projectId}/${id}` ); try { @@ -308,10 +321,8 @@ export class AtlasUserData extends IUserData { 'Content-Type': 'application/json', }, body: JSON.stringify({ - id: id, data: this.serialize(content), createdAt: new Date(), - projectId: this.projectId, }), }); @@ -404,7 +415,10 @@ export class AtlasUserData extends IUserData { headers: { 'Content-Type': 'application/json', }, - body: this.serialize(newData), + body: JSON.stringify({ + data: this.serialize(newData), + createdAt: new Date(), + }), } ); return true; diff --git a/packages/compass-web/sandbox/index.tsx b/packages/compass-web/sandbox/index.tsx index 17f676edf2b..addeb660aaa 100644 --- a/packages/compass-web/sandbox/index.tsx +++ b/packages/compass-web/sandbox/index.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useLayoutEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { - resetGlobalCSS, - css, Body, + css, openToast, + resetGlobalCSS, } from '@mongodb-js/compass-components'; import type { AllPreferences } from 'compass-preferences-model'; import { CompassWeb } from '../src/index'; @@ -137,6 +137,7 @@ const App = () => { isAtlas && !!enableGenAIFeaturesAtlasOrg, optInGenAIFeatures: isAtlas && !!optInGenAIFeatures, enableDataModeling: true, + enableMyQueries: false, }} onTrack={sandboxTelemetry.track} onDebug={sandboxLogger.debug} diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 8198dd81337..528fac571c9 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -15,17 +15,20 @@ import WorkspacesPlugin, { WorkspacesProvider, } from '@mongodb-js/compass-workspaces'; import { - DatabasesWorkspaceTab, CollectionsWorkspaceTab, + CreateNamespacePlugin, + DatabasesWorkspaceTab, + DropNamespacePlugin, + RenameCollectionPlugin, } from '@mongodb-js/compass-databases-collections'; import { CompassComponentsProvider, css } from '@mongodb-js/compass-components'; import { - WorkspaceTab as CollectionWorkspace, CollectionTabsProvider, + WorkspaceTab as CollectionWorkspace, } from '@mongodb-js/compass-collection'; import { - CompassSidebarPlugin, AtlasClusterConnectionsOnlyProvider, + CompassSidebarPlugin, } from '@mongodb-js/compass-sidebar'; import CompassQueryBarPlugin from '@mongodb-js/compass-query-bar'; import { CompassDocumentsPlugin } from '@mongodb-js/compass-crud'; @@ -40,28 +43,39 @@ import { CompassGlobalWritesPlugin } from '@mongodb-js/compass-global-writes'; import { CompassGenerativeAIPlugin } from '@mongodb-js/compass-generative-ai'; import ExplainPlanCollectionTabModal from '@mongodb-js/compass-explain-plan'; import ExportToLanguageCollectionTabModal from '@mongodb-js/compass-export-to-language'; -import { - CreateNamespacePlugin, - DropNamespacePlugin, - RenameCollectionPlugin, -} from '@mongodb-js/compass-databases-collections'; -import { PreferencesProvider } from 'compass-preferences-model/provider'; import type { AllPreferences } from 'compass-preferences-model/provider'; +import { PreferencesProvider } from 'compass-preferences-model/provider'; import FieldStorePlugin from '@mongodb-js/compass-field-store'; -import { AtlasServiceProvider } from '@mongodb-js/atlas-service/provider'; +import { + AtlasServiceProvider, + useAtlasServiceContext, +} from '@mongodb-js/atlas-service/provider'; import { AtlasAiServiceProvider } from '@mongodb-js/compass-generative-ai/provider'; import { LoggerProvider } from '@mongodb-js/compass-logging/provider'; import { TelemetryProvider } from '@mongodb-js/compass-telemetry/provider'; import CompassConnections from '@mongodb-js/compass-connections'; import { AtlasCloudConnectionStorageProvider } from './connection-storage'; import { AtlasCloudAuthServiceProvider } from './atlas-auth-service'; -import type { LogFunction, DebugFunction } from './logger'; +import type { DebugFunction, LogFunction } from './logger'; import { useCompassWebLogger } from './logger'; import { type TelemetryServiceOptions } from '@mongodb-js/compass-telemetry'; import { WebWorkspaceTab as WelcomeWorkspaceTab } from '@mongodb-js/compass-welcome'; +import { WorkspaceTab as MyQueriesWorkspace } from '@mongodb-js/compass-saved-aggregations-queries'; import { useCompassWebPreferences } from './preferences'; import { DataModelingWorkspaceTab as DataModelingWorkspace } from '@mongodb-js/compass-data-modeling'; import { DataModelStorageServiceProviderInMemory } from '@mongodb-js/compass-data-modeling/web'; +import { + CompassFavoriteQueryStorage, + CompassPipelineStorage, + CompassRecentQueryStorage, +} from '@mongodb-js/my-queries-storage'; +import { + type FavoriteQueryStorageAccess, + FavoriteQueryStorageProvider, + PipelineStorageProvider, + type RecentQueryStorageAccess, + RecentQueryStorageProvider, +} from '@mongodb-js/my-queries-storage/provider'; import { CompassAssistantDrawer, CompassAssistantProvider, @@ -87,6 +101,58 @@ const WithAtlasProviders: React.FC = ({ children }) => { ); }; +const WithStorageProviders: React.FC<{ orgId: string; projectId: string }> = ({ + children, + orgId, + projectId, +}) => { + const atlasService = useAtlasServiceContext(); + const authenticatedFetch = atlasService.authenticatedFetch.bind(atlasService); + const getResourceUrl = (path?: string) => { + const url = atlasService.userDataEndpoint(`/${path || ''}`); + return Promise.resolve(url); + }; + const pipelineStorage = useRef( + new CompassPipelineStorage({ + orgId, + projectId, + getResourceUrl, + authenticatedFetch, + }) + ); + const favoriteQueryStorage = useRef({ + getStorage(options) { + return new CompassFavoriteQueryStorage({ + basepath: options?.basepath, + orgId, + projectId, + getResourceUrl, + authenticatedFetch, + }); + }, + }); + const recentQueryStorage = useRef({ + getStorage(options) { + return new CompassRecentQueryStorage({ + basepath: options?.basepath, + orgId, + projectId, + getResourceUrl, + authenticatedFetch, + }); + }, + }); + return ( + + + + {children} + + + + ); +}; + type CompassWorkspaceProps = Pick< React.ComponentProps, 'initialWorkspaceTabs' | 'onActiveWorkspaceTabChange' @@ -189,6 +255,7 @@ function CompassWorkspace({ CollectionsWorkspaceTab, CollectionWorkspace, DataModelingWorkspace, + MyQueriesWorkspace, ]} > { - if (itemGroups.length > 0) { - onTrackRef.current?.('Context Menu Opened', { - item_groups: itemGroups.map((group) => group.telemetryLabel), - }); - } - }} - onContextMenuItemClick={(itemGroup, item) => { - onTrackRef.current?.('Context Menu Item Clicked', { - item_group: itemGroup.telemetryLabel, - item_label: item.label, - }); - }} + // TODO: Re-add context menu tracking once CompassComponentsProvider supports these props + // onContextMenuOpen and onContextMenuItemClick props are not available in current version onSignalMount={(id) => { onTrackRef.current?.('Signal Shown', { id }); }} @@ -384,68 +440,72 @@ const CompassWeb = ({ - - - { - return Promise.resolve([{}, null] as [ - Record, - null - ]); - }} - onAutoconnectInfoRequest={(connectionStore) => { - if (autoconnectId) { - return connectionStore.loadAll().then( - (connections) => { - return connections.find( - (connectionInfo) => - connectionInfo.id === autoconnectId - ); - }, - (err) => { - const { log, mongoLogId } = logger; - log.warn( - mongoLogId(1_001_000_329), - 'Compass Web', - 'Could not load connections when trying to autoconnect', - { err: err.message } - ); - return undefined; - } - ); - } - return Promise.resolve(undefined); - }} + + + - - - - - - - - - - - - - + { + return Promise.resolve([{}, null] as [ + Record, + null + ]); + }} + onAutoconnectInfoRequest={(connectionStore) => { + if (autoconnectId) { + return connectionStore.loadAll().then( + (connections) => { + return connections.find( + (connectionInfo) => + connectionInfo.id === autoconnectId + ); + }, + (err) => { + const { log, mongoLogId } = logger; + log.warn( + mongoLogId(1_001_000_329), + 'Compass Web', + 'Could not load connections when trying to autoconnect', + { err: err.message } + ); + return undefined; + } + ); + } + return Promise.resolve(undefined); + }} + > + + + + + + + + + + + + + + diff --git a/packages/compass-web/src/hooks/use-my-queries-feature.ts b/packages/compass-web/src/hooks/use-my-queries-feature.ts new file mode 100644 index 00000000000..55ccb635db8 --- /dev/null +++ b/packages/compass-web/src/hooks/use-my-queries-feature.ts @@ -0,0 +1,19 @@ +import { usePreference } from 'compass-preferences-model/provider'; + +/** + * Hook to check if the My Queries Data Explorer feature is enabled. + * This controls access to: + * - Saved queries and aggregations + * - Recent queries autocomplete + * - Favorite queries/aggregations + */ +export function useMyQueriesFeature(): boolean { + // This preference will be passed from MMS router as initialPreference + // based on settingsModel.hasProjectFeature('DATA_EXPLORER_SAVES_USER_DATA') + const enableMyQueries = usePreference('enableMyQueries'); + + // Debug logging to verify the preference is being passed correctly + console.log('useMyQueriesFeature - enableMyQueries:', enableMyQueries); + + return enableMyQueries ?? false; +} diff --git a/packages/compass-web/src/index.tsx b/packages/compass-web/src/index.tsx index f0760c92050..6e0f8f2e1d0 100644 --- a/packages/compass-web/src/index.tsx +++ b/packages/compass-web/src/index.tsx @@ -1,5 +1,6 @@ export { CompassWeb } from './entrypoint'; export type { CompassWebProps, TrackFunction } from './entrypoint'; +export { useMyQueriesFeature } from './hooks/use-my-queries-feature'; export * from './url-builder'; export type { OpenWorkspaceOptions, diff --git a/packages/my-queries-storage/src/compass-pipeline-storage.spec.ts b/packages/my-queries-storage/src/compass-pipeline-storage.spec.ts index 26329ab6cf3..32ce5f4fd1e 100644 --- a/packages/my-queries-storage/src/compass-pipeline-storage.spec.ts +++ b/packages/my-queries-storage/src/compass-pipeline-storage.spec.ts @@ -21,7 +21,7 @@ describe('CompassPipelineStorage', function () { beforeEach(async function () { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'saved-pipelines-tests')); - pipelineStorage = new CompassPipelineStorage(tmpDir); + pipelineStorage = new CompassPipelineStorage({ basePath: tmpDir }); }); afterEach(async function () { diff --git a/packages/my-queries-storage/src/compass-pipeline-storage.ts b/packages/my-queries-storage/src/compass-pipeline-storage.ts index d9484262d89..fd1ad30b29c 100644 --- a/packages/my-queries-storage/src/compass-pipeline-storage.ts +++ b/packages/my-queries-storage/src/compass-pipeline-storage.ts @@ -1,14 +1,49 @@ -import { FileUserData } from '@mongodb-js/compass-user-data'; -import { PipelineSchema } from './pipeline-storage-schema'; +import { + AtlasUserData, + FileUserData, + type IUserData, +} from '@mongodb-js/compass-user-data'; import type { SavedPipeline } from './pipeline-storage-schema'; +import { PipelineSchema } from './pipeline-storage-schema'; import type { PipelineStorage } from './pipeline-storage'; +export type PipelineStorageOptions = { + basePath?: string; + orgId?: string; + projectId?: string; + getResourceUrl?: (path?: string) => Promise; + authenticatedFetch?: ( + url: RequestInfo | URL, + options?: RequestInit + ) => Promise; +}; + export class CompassPipelineStorage implements PipelineStorage { - private readonly userData: FileUserData; - constructor(basePath?: string) { - this.userData = new FileUserData(PipelineSchema, 'SavedPipelines', { - basePath, - }); + private readonly userData: IUserData; + + constructor(options: PipelineStorageOptions = {}) { + const dataType = 'SavedPipelines'; + if ( + options.orgId && + options.projectId && + options.getResourceUrl && + options.authenticatedFetch + ) { + this.userData = new AtlasUserData( + PipelineSchema, + 'favoriteAggregations', + { + orgId: options.orgId, + projectId: options.projectId, + getResourceUrl: options.getResourceUrl, + authenticatedFetch: options.authenticatedFetch, + } + ); + } else { + this.userData = new FileUserData(PipelineSchema, dataType, { + basePath: options.basePath, + }); + } } async loadAll(): Promise { @@ -27,10 +62,6 @@ export class CompassPipelineStorage implements PipelineStorage { return this.loadAll().then((pipelines) => pipelines.filter(predicate)); } - private async loadOne(id: string): Promise { - return await this.userData.readOne(id); - } - async createOrUpdate( id: string, attributes: Omit @@ -72,4 +103,12 @@ export class CompassPipelineStorage implements PipelineStorage { async delete(id: string) { await this.userData.delete(id); } + + private async loadOne(id: string): Promise { + const result = await this.userData.readOne(id); + if (!result) { + throw new Error(`Pipeline with id ${id} not found`); + } + return result; + } } diff --git a/packages/my-queries-storage/src/compass-query-storage.ts b/packages/my-queries-storage/src/compass-query-storage.ts index dfcfbca36c5..6d8a113d4f8 100644 --- a/packages/my-queries-storage/src/compass-query-storage.ts +++ b/packages/my-queries-storage/src/compass-query-storage.ts @@ -1,26 +1,55 @@ -import { UUID, EJSON } from 'bson'; -import { type z } from '@mongodb-js/compass-user-data'; -import { type IUserData, FileUserData } from '@mongodb-js/compass-user-data'; -import { RecentQuerySchema, FavoriteQuerySchema } from './query-storage-schema'; +import { EJSON, ObjectId } from 'bson'; +import { + AtlasUserData, + FileUserData, + type IUserData, + type z, +} from '@mongodb-js/compass-user-data'; +import { FavoriteQuerySchema, RecentQuerySchema } from './query-storage-schema'; import type { FavoriteQueryStorage, RecentQueryStorage } from './query-storage'; export type QueryStorageOptions = { basepath?: string; + orgId?: string; + projectId?: string; + getResourceUrl?: (path?: string) => Promise; + authenticatedFetch?: ( + url: RequestInfo | URL, + options?: RequestInit + ) => Promise; }; export abstract class CompassQueryStorage { protected readonly userData: IUserData; + constructor( schemaValidator: TSchema, - protected readonly folder: string, + protected readonly dataType: string, protected readonly options: QueryStorageOptions ) { - // TODO: logic for whether we're in compass web or compass desktop - this.userData = new FileUserData(schemaValidator, folder, { - basePath: options.basepath, - serialize: (content) => EJSON.stringify(content, undefined, 2), - deserialize: (content: string) => EJSON.parse(content), - }); + if ( + options.orgId && + options.projectId && + options.getResourceUrl && + options.authenticatedFetch + ) { + const type = + dataType === 'RecentQueries' ? 'recentQueries' : 'favoriteQueries'; + this.userData = new AtlasUserData(schemaValidator, type, { + orgId: options.orgId, + projectId: options.projectId, + getResourceUrl: options.getResourceUrl, + authenticatedFetch: options.authenticatedFetch, + serialize: (content: any) => EJSON.stringify(content, undefined), + deserialize: (content: string) => EJSON.parse(content), + }); + } else { + this.userData = new FileUserData(schemaValidator, dataType, { + basePath: options.basepath, + serialize: (content: any) => EJSON.stringify(content, undefined), + deserialize: (content: string) => EJSON.parse(content), + }); + } } async loadAll(namespace?: string): Promise[]> { @@ -74,8 +103,8 @@ export class CompassRecentQueryStorage const lastRecent = recentQueries[recentQueries.length - 1]; await this.delete(lastRecent._id); } - - const _id = new UUID().toString(); + // TODO: verify that this doesn't break anything in compass + const _id = new ObjectId().toHexString(); // this creates a recent query that we will write to system/db const recentQuery = { ...data, @@ -98,9 +127,11 @@ export class CompassFavoriteQueryStorage data: Omit< z.input, '_id' | '_lastExecuted' | '_dateModified' | '_dateSaved' - > + >, + _id?: string ): Promise { - const _id = new UUID().toString(); + // TODO: verify that this doesn't break anything in compass + _id ??= new ObjectId().toHexString(); // this creates a favorite query that we will write to system/db const favoriteQuery = { ...data, diff --git a/packages/my-queries-storage/src/query-storage-schema.ts b/packages/my-queries-storage/src/query-storage-schema.ts index 29f27a1e749..cd74a028302 100644 --- a/packages/my-queries-storage/src/query-storage-schema.ts +++ b/packages/my-queries-storage/src/query-storage-schema.ts @@ -12,7 +12,7 @@ const queryProps = { }; const commonMetadata = { - _id: z.string().uuid(), + _id: z.string(), _lastExecuted: z .union([z.coerce.date(), z.number()]) .transform((x) => new Date(x)), diff --git a/packages/my-queries-storage/src/query-storage.ts b/packages/my-queries-storage/src/query-storage.ts index 9ee445a646c..522311e0531 100644 --- a/packages/my-queries-storage/src/query-storage.ts +++ b/packages/my-queries-storage/src/query-storage.ts @@ -23,6 +23,7 @@ export interface FavoriteQueryStorage data: Omit< z.input, '_id' | '_lastExecuted' | '_dateModified' | '_dateSaved' - > + >, + id?: string ): Promise; }