From d210354b4ea98778244d828705c2b8a152718a09 Mon Sep 17 00:00:00 2001 From: Meisam Seyed Aliroteh Date: Wed, 9 Oct 2024 11:29:43 -0700 Subject: [PATCH] feat: prompt user to select lightning experince app --- messages/prompts.md | 4 ++ src/commands/lightning/dev/app.ts | 14 +------ src/shared/orgUtils.ts | 53 ++++++++++++++++++++++--- src/shared/previewUtils.ts | 35 ++++++++++++++++ src/shared/promptUtils.ts | 15 ++++++- test/commands/lightning/dev/app.test.ts | 46 ++++++++++++--------- test/shared/orgUtils.test.ts | 12 +++--- 7 files changed, 135 insertions(+), 44 deletions(-) diff --git a/messages/prompts.md b/messages/prompts.md index 0759094a..2fd9392d 100644 --- a/messages/prompts.md +++ b/messages/prompts.md @@ -6,6 +6,10 @@ Select a site An updated site bundle is available for "%s". Do you want to download and apply the update? +# lightning-experience-app.title + +Which Lightning Experience App do you want to use for the preview? + # device-type.title Which device type do you want to use for the preview? diff --git a/src/commands/lightning/dev/app.ts b/src/commands/lightning/dev/app.ts index 254a24f5..ba232b3d 100644 --- a/src/commands/lightning/dev/app.ts +++ b/src/commands/lightning/dev/app.ts @@ -97,19 +97,7 @@ export default class LightningDevApp extends SfCommand { return Promise.reject(new Error(messages.getMessage('error.identitydata.entityid'))); } - let appId: string | undefined; - if (appName) { - logger.debug(`Determining App Id for ${appName}`); - - // The appName is optional but if the user did provide an appName then it must be - // a valid one.... meaning that it should resolve to a valid appId. - appId = await OrgUtils.getAppId(connection, appName); - if (!appId) { - return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName]))); - } - - logger.debug(`App Id is ${appId}`); - } + const appId = await PreviewUtils.getLightningExperienceAppId(connection, appName, logger); logger.debug('Determining the next available port for Local Dev Server'); const serverPorts = await PreviewUtils.getNextAvailablePorts(); diff --git a/src/shared/orgUtils.ts b/src/shared/orgUtils.ts index a5dcf97b..1bd4007a 100644 --- a/src/shared/orgUtils.ts +++ b/src/shared/orgUtils.ts @@ -11,18 +11,26 @@ type LightningPreviewMetadataResponse = { enableLightningPreviewPref?: string; }; +export type AppDefinition = { + DeveloperName: string; + Label: string; + Description: string; + DurableId: string; +}; + export class OrgUtils { /** - * Given an app name, it queries the org to find the matching app id. To do so, - * it will first attempt at finding the app with a matching DeveloperName. If - * no match is found, it will then attempt at finding the app with a matching - * Label. If multiple matches are found, then the first match is returned. + * Given an app name, it queries the AppDefinition table in the org to find + * the DurableId for the app. To do so, it will first attempt at finding the + * app with a matching DeveloperName. If no match is found, it will then + * attempt at finding the app with a matching Label. If multiple matches are + * found, then the first match is returned. * * @param connection the connection to the org * @param appName the name of the app - * @returns the app id or undefined if no match is found + * @returns the DurableId for the app as found in the AppDefinition table or undefined if no match is found */ - public static async getAppId(connection: Connection, appName: string): Promise { + public static async getAppDefinitionDurableId(connection: Connection, appName: string): Promise { // NOTE: We have to break up the query and run against different columns separately instead // of using OR statement, otherwise we'll get the error 'Disjunctions not supported' const devNameQuery = `SELECT DurableId FROM AppDefinition WHERE DeveloperName LIKE '${appName}'`; @@ -43,6 +51,39 @@ export class OrgUtils { return undefined; } + /** + * Queries the org and returns a list of the lightning experience apps in the org that are visible to and accessible by the user. + * + * @param connection the connection to the org + * @returns a list of the lightning experience apps in the org that are visible to and accessible by the user. + */ + public static async getLightningExperienceAppList(connection: Connection): Promise { + const results: AppDefinition[] = []; + + const appMenuItemsQuery = + 'SELECT Label,Description,Name FROM AppMenuItem WHERE IsAccessible=true AND IsVisible=TRUE'; + const appMenuItems = await connection.query<{ Label: string; Description: string; Name: string }>( + appMenuItemsQuery + ); + + const appDefinitionsQuery = "SELECT DeveloperName,DurableId FROM AppDefinition WHERE UiType='Lightning'"; + const appDefinitions = await connection.query<{ DeveloperName: string; DurableId: string }>(appDefinitionsQuery); + + appMenuItems.records.forEach((item) => { + const match = appDefinitions.records.find((definition) => definition.DeveloperName === item.Name); + if (match) { + results.push({ + DeveloperName: match.DeveloperName, + Label: item.Label, + Description: item.Description, + DurableId: match.DurableId, + }); + } + }); + + return results; + } + /** * Checks to see if Local Dev is enabled for the org. * diff --git a/src/shared/previewUtils.ts b/src/shared/previewUtils.ts index 5e8023ea..c70ef718 100644 --- a/src/shared/previewUtils.ts +++ b/src/shared/previewUtils.ts @@ -104,6 +104,41 @@ export class PreviewUtils { return Promise.resolve(device); } + /** + * If an app name is provided then it will query the org to determine the DurableId for the provided app. + * Otherwise it will get a list of all of the lightning experience apps in the org that are visible/accessible + * by the user, prompts the user to select one, then returns the DurableId of the selected app. + * + * @param connection the connection to the org + * @param appName optional - either the DeveloperName or Label for an app + * @param logger optional - logger to be used for logging + * @returns the DurableId for an app. + */ + public static async getLightningExperienceAppId( + connection: Connection, + appName?: string, + logger?: Logger + ): Promise { + if (appName) { + logger?.debug(`Determining App Id for ${appName}`); + + // The appName is optional but if the user did provide an appName then it must be + // a valid one.... meaning that it should resolve to a valid appId. + const appId = await OrgUtils.getAppDefinitionDurableId(connection, appName); + if (!appId) { + return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName]))); + } + + logger?.debug(`App Id is ${appId} for ${appName}`); + return appId; + } else { + logger?.debug('Prompting the user to select an app.'); + const appDefinition = await PromptUtils.promptUserToSelectLightningExperienceApp(connection); + logger?.debug(`App Id is ${appDefinition.DurableId} for ${appDefinition.Label}`); + return appDefinition.DurableId; + } + } + /** * Generates the proper set of arguments to be used for launching desktop browser and navigating to the right location. * diff --git a/src/shared/promptUtils.ts b/src/shared/promptUtils.ts index 4a89b221..1b9c6238 100644 --- a/src/shared/promptUtils.ts +++ b/src/shared/promptUtils.ts @@ -6,7 +6,7 @@ */ import select from '@inquirer/select'; import { confirm } from '@inquirer/prompts'; -import { Logger, Messages } from '@salesforce/core'; +import { Connection, Logger, Messages } from '@salesforce/core'; import { AndroidDeviceManager, AppleDeviceManager, @@ -14,6 +14,7 @@ import { Platform, Version, } from '@salesforce/lwc-dev-mobile-core'; +import { AppDefinition, OrgUtils } from './orgUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'prompts'); @@ -51,6 +52,18 @@ export class PromptUtils { return response; } + public static async promptUserToSelectLightningExperienceApp(connection: Connection): Promise { + const apps = await OrgUtils.getLightningExperienceAppList(connection); + const choices = apps.map((app) => ({ name: app.Label, value: app })); + + const response = await select({ + message: messages.getMessage('lightning-experience-app.title'), + choices, + }); + + return response; + } + public static async promptUserToSelectMobileDevice( platform: Platform.ios | Platform.android, logger?: Logger diff --git a/test/commands/lightning/dev/app.test.ts b/test/commands/lightning/dev/app.test.ts index c6935ee2..f1167737 100644 --- a/test/commands/lightning/dev/app.test.ts +++ b/test/commands/lightning/dev/app.test.ts @@ -29,7 +29,7 @@ import LightningDevApp, { androidSalesforceAppPreviewConfig, iOSSalesforceAppPreviewConfig, } from '../../../../src/commands/lightning/dev/app.js'; -import { OrgUtils } from '../../../../src/shared/orgUtils.js'; +import { AppDefinition, OrgUtils } from '../../../../src/shared/orgUtils.js'; import { PreviewUtils } from '../../../../src/shared/previewUtils.js'; import { ConfigUtils, LocalWebServerIdentityData } from '../../../../src/shared/configUtils.js'; import { PromptUtils } from '../../../../src/shared/promptUtils.js'; @@ -41,7 +41,12 @@ describe('lightning dev app', () => { const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils'); const $$ = new TestContext(); const testOrgData = new MockTestOrgData(); - const testAppId = '06m8b000002vpFSAAY'; + const testAppDefinition: AppDefinition = { + DeveloperName: 'TestApp', + DurableId: '06m8b000002vpFSAAY', + Label: 'Test App', + Description: 'An app to be used for unit testing', + }; const testServerUrl = 'wss://localhost:1234'; const testIOSDevice = new AppleDevice( 'F2B4097F-F33E-4D8A-8FFF-CE49F8D6C166', @@ -112,7 +117,7 @@ describe('lightning dev app', () => { it('throws when app not found', async () => { try { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(undefined); await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]); } catch (err) { expect(err) @@ -124,7 +129,7 @@ describe('lightning dev app', () => { it('throws when username not found', async () => { try { $$.SANDBOX.restore(); - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(undefined); $$.SANDBOX.stub(Connection.prototype, 'getUsername').returns(undefined); await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]); } catch (err) { @@ -134,7 +139,7 @@ describe('lightning dev app', () => { it('throws when cannot determine ldp server url', async () => { try { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').throws( new Error('Cannot determine LDP url.') ); @@ -147,20 +152,25 @@ describe('lightning dev app', () => { describe('desktop dev', () => { it('prompts user to select platform when not provided', async () => { const promptStub = $$.SANDBOX.stub(PromptUtils, 'promptUserToSelectPlatform').resolves(Platform.desktop); - await verifyOrgOpen('lightning'); + $$.SANDBOX.stub(PromptUtils, 'promptUserToSelectLightningExperienceApp').resolves(testAppDefinition); + await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`); expect(promptStub.calledOnce); }); it('runs org:open with proper flags when app name provided', async () => { - await verifyOrgOpen(`lightning/app/${testAppId}`, Platform.desktop, 'Sales'); + await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`, Platform.desktop, 'Sales'); }); - it('runs org:open with proper flags when no app name provided', async () => { - await verifyOrgOpen('lightning', Platform.desktop); + it('prompts user to select lightning app when not provided', async () => { + const promptStub = $$.SANDBOX.stub(PromptUtils, 'promptUserToSelectLightningExperienceApp').resolves( + testAppDefinition + ); + await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`, Platform.desktop); + expect(promptStub.calledOnce); }); async function verifyOrgOpen(expectedAppPath: string, deviceType?: Platform, appName?: string): Promise { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData); @@ -192,7 +202,7 @@ describe('lightning dev app', () => { describe('mobile dev', () => { it('throws when environment setup requirements are not met', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); @@ -203,7 +213,7 @@ describe('lightning dev app', () => { }); it('throws when unable to fetch mobile device', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); @@ -216,7 +226,7 @@ describe('lightning dev app', () => { }); it('throws when device fails to boot', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); @@ -231,7 +241,7 @@ describe('lightning dev app', () => { }); it('throws when cannot generate certificate', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); @@ -251,7 +261,7 @@ describe('lightning dev app', () => { }); it('throws if user chooses not to install app on mobile device', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); @@ -269,7 +279,7 @@ describe('lightning dev app', () => { }); it('prompts user to select mobile device when not provided', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData); @@ -285,7 +295,7 @@ describe('lightning dev app', () => { }); it('installs and launches app on mobile device', async () => { - $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); + $$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData); @@ -394,7 +404,7 @@ describe('lightning dev app', () => { expectedLdpServerUrl, testLdpServerId, 'Sales', - testAppId + testAppDefinition.DurableId ); const downloadStub = $$.SANDBOX.stub(PreviewUtils, 'downloadSalesforceMobileAppBundle').resolves( diff --git a/test/shared/orgUtils.test.ts b/test/shared/orgUtils.test.ts index 810b7ae8..05a6f5bf 100644 --- a/test/shared/orgUtils.test.ts +++ b/test/shared/orgUtils.test.ts @@ -17,23 +17,23 @@ describe('orgUtils', () => { $$.restore(); }); - it('getAppId returns undefined when no matches found', async () => { + it('getAppDefinitionDurableId returns undefined when no matches found', async () => { $$.SANDBOX.stub(Connection.prototype, 'query').resolves({ records: [], done: true, totalSize: 0 }); - const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'blah'); + const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'blah'); expect(appId).to.be.undefined; }); - it('getAppId returns first match when multiple matches found', async () => { + it('getAppDefinitionDurableId returns first match when multiple matches found', async () => { $$.SANDBOX.stub(Connection.prototype, 'query').resolves({ records: [{ DurableId: 'id1' }, { DurableId: 'id2' }], done: true, totalSize: 2, }); - const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'Sales'); + const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'Sales'); expect(appId).to.be.equal('id1'); }); - it('getAppId uses Label if DeveloperName produces no matches', async () => { + it('getAppDefinitionDurableId uses Label if DeveloperName produces no matches', async () => { const noMatches = { records: [], done: true, totalSize: 0 }; const matches = { records: [{ DurableId: 'id1' }, { DurableId: 'id2' }], done: true, totalSize: 2 }; const stub = $$.SANDBOX.stub(Connection.prototype, 'query') @@ -41,7 +41,7 @@ describe('orgUtils', () => { .resolves(noMatches) .onSecondCall() .resolves(matches); - const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'Sales'); + const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'Sales'); expect(appId).to.be.equal('id1'); expect(stub.getCall(0).args[0]).to.include('DeveloperName'); expect(stub.getCall(1).args[0]).to.include('Label');