Skip to content

Commit e4fee0e

Browse files
authored
feat: prompt user to select lightning experince app (#198)
1 parent dd01025 commit e4fee0e

File tree

7 files changed

+135
-44
lines changed

7 files changed

+135
-44
lines changed

messages/prompts.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ Select a site
66

77
An updated site bundle is available for "%s". Do you want to download and apply the update?
88

9+
# lightning-experience-app.title
10+
11+
Which Lightning Experience App do you want to use for the preview?
12+
913
# device-type.title
1014

1115
Which device type do you want to use for the preview?

src/commands/lightning/dev/app.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,7 @@ export default class LightningDevApp extends SfCommand<void> {
9797
return Promise.reject(new Error(messages.getMessage('error.identitydata.entityid')));
9898
}
9999

100-
let appId: string | undefined;
101-
if (appName) {
102-
logger.debug(`Determining App Id for ${appName}`);
103-
104-
// The appName is optional but if the user did provide an appName then it must be
105-
// a valid one.... meaning that it should resolve to a valid appId.
106-
appId = await OrgUtils.getAppId(connection, appName);
107-
if (!appId) {
108-
return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName])));
109-
}
110-
111-
logger.debug(`App Id is ${appId}`);
112-
}
100+
const appId = await PreviewUtils.getLightningExperienceAppId(connection, appName, logger);
113101

114102
logger.debug('Determining the next available port for Local Dev Server');
115103
const serverPorts = await PreviewUtils.getNextAvailablePorts();

src/shared/orgUtils.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,26 @@ type LightningPreviewMetadataResponse = {
1111
enableLightningPreviewPref?: string;
1212
};
1313

14+
export type AppDefinition = {
15+
DeveloperName: string;
16+
Label: string;
17+
Description: string;
18+
DurableId: string;
19+
};
20+
1421
export class OrgUtils {
1522
/**
16-
* Given an app name, it queries the org to find the matching app id. To do so,
17-
* it will first attempt at finding the app with a matching DeveloperName. If
18-
* no match is found, it will then attempt at finding the app with a matching
19-
* Label. If multiple matches are found, then the first match is returned.
23+
* Given an app name, it queries the AppDefinition table in the org to find
24+
* the DurableId for the app. To do so, it will first attempt at finding the
25+
* app with a matching DeveloperName. If no match is found, it will then
26+
* attempt at finding the app with a matching Label. If multiple matches are
27+
* found, then the first match is returned.
2028
*
2129
* @param connection the connection to the org
2230
* @param appName the name of the app
23-
* @returns the app id or undefined if no match is found
31+
* @returns the DurableId for the app as found in the AppDefinition table or undefined if no match is found
2432
*/
25-
public static async getAppId(connection: Connection, appName: string): Promise<string | undefined> {
33+
public static async getAppDefinitionDurableId(connection: Connection, appName: string): Promise<string | undefined> {
2634
// NOTE: We have to break up the query and run against different columns separately instead
2735
// of using OR statement, otherwise we'll get the error 'Disjunctions not supported'
2836
const devNameQuery = `SELECT DurableId FROM AppDefinition WHERE DeveloperName LIKE '${appName}'`;
@@ -43,6 +51,39 @@ export class OrgUtils {
4351
return undefined;
4452
}
4553

54+
/**
55+
* Queries the org and returns a list of the lightning experience apps in the org that are visible to and accessible by the user.
56+
*
57+
* @param connection the connection to the org
58+
* @returns a list of the lightning experience apps in the org that are visible to and accessible by the user.
59+
*/
60+
public static async getLightningExperienceAppList(connection: Connection): Promise<AppDefinition[]> {
61+
const results: AppDefinition[] = [];
62+
63+
const appMenuItemsQuery =
64+
'SELECT Label,Description,Name FROM AppMenuItem WHERE IsAccessible=true AND IsVisible=TRUE';
65+
const appMenuItems = await connection.query<{ Label: string; Description: string; Name: string }>(
66+
appMenuItemsQuery
67+
);
68+
69+
const appDefinitionsQuery = "SELECT DeveloperName,DurableId FROM AppDefinition WHERE UiType='Lightning'";
70+
const appDefinitions = await connection.query<{ DeveloperName: string; DurableId: string }>(appDefinitionsQuery);
71+
72+
appMenuItems.records.forEach((item) => {
73+
const match = appDefinitions.records.find((definition) => definition.DeveloperName === item.Name);
74+
if (match) {
75+
results.push({
76+
DeveloperName: match.DeveloperName,
77+
Label: item.Label,
78+
Description: item.Description,
79+
DurableId: match.DurableId,
80+
});
81+
}
82+
});
83+
84+
return results;
85+
}
86+
4687
/**
4788
* Checks to see if Local Dev is enabled for the org.
4889
*

src/shared/previewUtils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,41 @@ export class PreviewUtils {
104104
return Promise.resolve(device);
105105
}
106106

107+
/**
108+
* If an app name is provided then it will query the org to determine the DurableId for the provided app.
109+
* Otherwise it will get a list of all of the lightning experience apps in the org that are visible/accessible
110+
* by the user, prompts the user to select one, then returns the DurableId of the selected app.
111+
*
112+
* @param connection the connection to the org
113+
* @param appName optional - either the DeveloperName or Label for an app
114+
* @param logger optional - logger to be used for logging
115+
* @returns the DurableId for an app.
116+
*/
117+
public static async getLightningExperienceAppId(
118+
connection: Connection,
119+
appName?: string,
120+
logger?: Logger
121+
): Promise<string> {
122+
if (appName) {
123+
logger?.debug(`Determining App Id for ${appName}`);
124+
125+
// The appName is optional but if the user did provide an appName then it must be
126+
// a valid one.... meaning that it should resolve to a valid appId.
127+
const appId = await OrgUtils.getAppDefinitionDurableId(connection, appName);
128+
if (!appId) {
129+
return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName])));
130+
}
131+
132+
logger?.debug(`App Id is ${appId} for ${appName}`);
133+
return appId;
134+
} else {
135+
logger?.debug('Prompting the user to select an app.');
136+
const appDefinition = await PromptUtils.promptUserToSelectLightningExperienceApp(connection);
137+
logger?.debug(`App Id is ${appDefinition.DurableId} for ${appDefinition.Label}`);
138+
return appDefinition.DurableId;
139+
}
140+
}
141+
107142
/**
108143
* Generates the proper set of arguments to be used for launching desktop browser and navigating to the right location.
109144
*

src/shared/promptUtils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
*/
77
import select from '@inquirer/select';
88
import { confirm } from '@inquirer/prompts';
9-
import { Logger, Messages } from '@salesforce/core';
9+
import { Connection, Logger, Messages } from '@salesforce/core';
1010
import {
1111
AndroidDeviceManager,
1212
AppleDeviceManager,
1313
BaseDevice,
1414
Platform,
1515
Version,
1616
} from '@salesforce/lwc-dev-mobile-core';
17+
import { AppDefinition, OrgUtils } from './orgUtils.js';
1718

1819
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1920
const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'prompts');
@@ -51,6 +52,18 @@ export class PromptUtils {
5152
return response;
5253
}
5354

55+
public static async promptUserToSelectLightningExperienceApp(connection: Connection): Promise<AppDefinition> {
56+
const apps = await OrgUtils.getLightningExperienceAppList(connection);
57+
const choices = apps.map((app) => ({ name: app.Label, value: app }));
58+
59+
const response = await select({
60+
message: messages.getMessage('lightning-experience-app.title'),
61+
choices,
62+
});
63+
64+
return response;
65+
}
66+
5467
public static async promptUserToSelectMobileDevice(
5568
platform: Platform.ios | Platform.android,
5669
logger?: Logger

test/commands/lightning/dev/app.test.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import LightningDevApp, {
2929
androidSalesforceAppPreviewConfig,
3030
iOSSalesforceAppPreviewConfig,
3131
} from '../../../../src/commands/lightning/dev/app.js';
32-
import { OrgUtils } from '../../../../src/shared/orgUtils.js';
32+
import { AppDefinition, OrgUtils } from '../../../../src/shared/orgUtils.js';
3333
import { PreviewUtils } from '../../../../src/shared/previewUtils.js';
3434
import { ConfigUtils, LocalWebServerIdentityData } from '../../../../src/shared/configUtils.js';
3535
import { PromptUtils } from '../../../../src/shared/promptUtils.js';
@@ -41,7 +41,12 @@ describe('lightning dev app', () => {
4141
const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils');
4242
const $$ = new TestContext();
4343
const testOrgData = new MockTestOrgData();
44-
const testAppId = '06m8b000002vpFSAAY';
44+
const testAppDefinition: AppDefinition = {
45+
DeveloperName: 'TestApp',
46+
DurableId: '06m8b000002vpFSAAY',
47+
Label: 'Test App',
48+
Description: 'An app to be used for unit testing',
49+
};
4550
const testServerUrl = 'wss://localhost:1234';
4651
const testIOSDevice = new AppleDevice(
4752
'F2B4097F-F33E-4D8A-8FFF-CE49F8D6C166',
@@ -112,7 +117,7 @@ describe('lightning dev app', () => {
112117

113118
it('throws when app not found', async () => {
114119
try {
115-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined);
120+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(undefined);
116121
await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]);
117122
} catch (err) {
118123
expect(err)
@@ -124,7 +129,7 @@ describe('lightning dev app', () => {
124129
it('throws when username not found', async () => {
125130
try {
126131
$$.SANDBOX.restore();
127-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined);
132+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(undefined);
128133
$$.SANDBOX.stub(Connection.prototype, 'getUsername').returns(undefined);
129134
await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username, '-t', Platform.desktop]);
130135
} catch (err) {
@@ -134,7 +139,7 @@ describe('lightning dev app', () => {
134139

135140
it('throws when cannot determine ldp server url', async () => {
136141
try {
137-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
142+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
138143
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').throws(
139144
new Error('Cannot determine LDP url.')
140145
);
@@ -147,20 +152,25 @@ describe('lightning dev app', () => {
147152
describe('desktop dev', () => {
148153
it('prompts user to select platform when not provided', async () => {
149154
const promptStub = $$.SANDBOX.stub(PromptUtils, 'promptUserToSelectPlatform').resolves(Platform.desktop);
150-
await verifyOrgOpen('lightning');
155+
$$.SANDBOX.stub(PromptUtils, 'promptUserToSelectLightningExperienceApp').resolves(testAppDefinition);
156+
await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`);
151157
expect(promptStub.calledOnce);
152158
});
153159

154160
it('runs org:open with proper flags when app name provided', async () => {
155-
await verifyOrgOpen(`lightning/app/${testAppId}`, Platform.desktop, 'Sales');
161+
await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`, Platform.desktop, 'Sales');
156162
});
157163

158-
it('runs org:open with proper flags when no app name provided', async () => {
159-
await verifyOrgOpen('lightning', Platform.desktop);
164+
it('prompts user to select lightning app when not provided', async () => {
165+
const promptStub = $$.SANDBOX.stub(PromptUtils, 'promptUserToSelectLightningExperienceApp').resolves(
166+
testAppDefinition
167+
);
168+
await verifyOrgOpen(`lightning/app/${testAppDefinition.DurableId}`, Platform.desktop);
169+
expect(promptStub.calledOnce);
160170
});
161171

162172
async function verifyOrgOpen(expectedAppPath: string, deviceType?: Platform, appName?: string): Promise<void> {
163-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
173+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
164174
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
165175
$$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData);
166176

@@ -192,7 +202,7 @@ describe('lightning dev app', () => {
192202

193203
describe('mobile dev', () => {
194204
it('throws when environment setup requirements are not met', async () => {
195-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
205+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
196206
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
197207

198208
$$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves();
@@ -203,7 +213,7 @@ describe('lightning dev app', () => {
203213
});
204214

205215
it('throws when unable to fetch mobile device', async () => {
206-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
216+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
207217
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
208218

209219
$$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves();
@@ -216,7 +226,7 @@ describe('lightning dev app', () => {
216226
});
217227

218228
it('throws when device fails to boot', async () => {
219-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
229+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
220230
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
221231

222232
$$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves();
@@ -231,7 +241,7 @@ describe('lightning dev app', () => {
231241
});
232242

233243
it('throws when cannot generate certificate', async () => {
234-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
244+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
235245
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
236246

237247
$$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves();
@@ -251,7 +261,7 @@ describe('lightning dev app', () => {
251261
});
252262

253263
it('throws if user chooses not to install app on mobile device', async () => {
254-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
264+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
255265
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
256266

257267
$$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves();
@@ -269,7 +279,7 @@ describe('lightning dev app', () => {
269279
});
270280

271281
it('prompts user to select mobile device when not provided', async () => {
272-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
282+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
273283
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
274284
$$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData);
275285

@@ -285,7 +295,7 @@ describe('lightning dev app', () => {
285295
});
286296

287297
it('installs and launches app on mobile device', async () => {
288-
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId);
298+
$$.SANDBOX.stub(OrgUtils, 'getAppDefinitionDurableId').resolves(testAppDefinition.DurableId);
289299
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
290300
$$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(testIdentityData);
291301

@@ -394,7 +404,7 @@ describe('lightning dev app', () => {
394404
expectedLdpServerUrl,
395405
testLdpServerId,
396406
'Sales',
397-
testAppId
407+
testAppDefinition.DurableId
398408
);
399409

400410
const downloadStub = $$.SANDBOX.stub(PreviewUtils, 'downloadSalesforceMobileAppBundle').resolves(

test/shared/orgUtils.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,31 @@ describe('orgUtils', () => {
1717
$$.restore();
1818
});
1919

20-
it('getAppId returns undefined when no matches found', async () => {
20+
it('getAppDefinitionDurableId returns undefined when no matches found', async () => {
2121
$$.SANDBOX.stub(Connection.prototype, 'query').resolves({ records: [], done: true, totalSize: 0 });
22-
const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'blah');
22+
const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'blah');
2323
expect(appId).to.be.undefined;
2424
});
2525

26-
it('getAppId returns first match when multiple matches found', async () => {
26+
it('getAppDefinitionDurableId returns first match when multiple matches found', async () => {
2727
$$.SANDBOX.stub(Connection.prototype, 'query').resolves({
2828
records: [{ DurableId: 'id1' }, { DurableId: 'id2' }],
2929
done: true,
3030
totalSize: 2,
3131
});
32-
const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'Sales');
32+
const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'Sales');
3333
expect(appId).to.be.equal('id1');
3434
});
3535

36-
it('getAppId uses Label if DeveloperName produces no matches', async () => {
36+
it('getAppDefinitionDurableId uses Label if DeveloperName produces no matches', async () => {
3737
const noMatches = { records: [], done: true, totalSize: 0 };
3838
const matches = { records: [{ DurableId: 'id1' }, { DurableId: 'id2' }], done: true, totalSize: 2 };
3939
const stub = $$.SANDBOX.stub(Connection.prototype, 'query')
4040
.onFirstCall()
4141
.resolves(noMatches)
4242
.onSecondCall()
4343
.resolves(matches);
44-
const appId = await OrgUtils.getAppId(new Connection({ authInfo: new AuthInfo() }), 'Sales');
44+
const appId = await OrgUtils.getAppDefinitionDurableId(new Connection({ authInfo: new AuthInfo() }), 'Sales');
4545
expect(appId).to.be.equal('id1');
4646
expect(stub.getCall(0).args[0]).to.include('DeveloperName');
4747
expect(stub.getCall(1).args[0]).to.include('Label');

0 commit comments

Comments
 (0)