Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions messages/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
14 changes: 1 addition & 13 deletions src/commands/lightning/dev/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,7 @@ export default class LightningDevApp extends SfCommand<void> {
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();
Expand Down
53 changes: 47 additions & 6 deletions src/shared/orgUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
public static async getAppDefinitionDurableId(connection: Connection, appName: string): Promise<string | undefined> {
// 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}'`;
Expand All @@ -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<AppDefinition[]> {
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,
});
}
});
Comment on lines +63 to +82
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we would do all of this in a single SQL statement using JOIN but as I understand it SF SOQL statements don't support JOIN so instead I run 2 query and filter in code (since it won't be the case that an org would have thousands and thousands of apps... but rather a handful of apps)


return results;
}

/**
* Checks to see if Local Dev is enabled for the org.
*
Expand Down
35 changes: 35 additions & 0 deletions src/shared/previewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If appName is not valid, should user be prompted too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not part of the UX design so we error out instead (which is consistent with the behavior of the Beta version).

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.
*
Expand Down
15 changes: 14 additions & 1 deletion src/shared/promptUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
*/
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,
BaseDevice,
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');
Expand Down Expand Up @@ -51,6 +52,18 @@ export class PromptUtils {
return response;
}

public static async promptUserToSelectLightningExperienceApp(connection: Connection): Promise<AppDefinition> {
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
Expand Down
46 changes: 28 additions & 18 deletions test/commands/lightning/dev/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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.')
);
Expand All @@ -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<void> {
$$.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);

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -394,7 +404,7 @@ describe('lightning dev app', () => {
expectedLdpServerUrl,
testLdpServerId,
'Sales',
testAppId
testAppDefinition.DurableId
);

const downloadStub = $$.SANDBOX.stub(PreviewUtils, 'downloadSalesforceMobileAppBundle').resolves(
Expand Down
12 changes: 6 additions & 6 deletions test/shared/orgUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,31 @@ 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')
.onFirstCall()
.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');
Expand Down