Skip to content

Commit 8497b17

Browse files
committed
Refactors SPFx actions. Closes #431, #430 (#468)
## 🎯 Aim The aim of this PR is to refactor SPFx actions to show progress when they are running and handle a special case when we try to install an app which is already installed to suggest either upgrade if it is the same app or remove if it is a different version of the app with the same version. ## 📷 Result ![image](https://github.com/user-attachments/assets/c52ceadd-4799-4d1d-af99-fdbbc5f26b1e) ![image](https://github.com/user-attachments/assets/10e2bbf3-18cd-42a9-96ad-a52e8c13f6a8) ## ✅ What was done - [X] Added show progress notify for all SPFx app actions - [X] Moved SPFx app actions to separate file - [X] Added handling when app installed or different app with same if installed path with quick remove or upgrade action in notify ## 🔗 Related issue I know I know... one issue one PR, but I couldn't help myself to do it in a single change 😜 Closes: #431, #430
1 parent 1a5a7fa commit 8497b17

File tree

3 files changed

+433
-344
lines changed

3 files changed

+433
-344
lines changed

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { CHAT_PARTICIPANT_NAME, ProjectFileContent } from './constants';
1414
import { EntraAppRegistration } from './services/actions/EntraAppRegistration';
1515
import { CopilotActions } from './services/actions/CopilotActions';
1616
import { ChatTools } from './chat/tools/ChatTools';
17+
import { SpfxAppCLIActions } from './services/actions/SpfxAppCLIActions';
1718

1819

1920
export async function activate(context: vscode.ExtensionContext) {
@@ -32,6 +33,7 @@ export async function activate(context: vscode.ExtensionContext) {
3233
Dependencies.registerCommands();
3334
Scaffolder.registerCommands();
3435
CliActions.registerCommands();
36+
SpfxAppCLIActions.registerCommands();
3537
EntraAppRegistration.registerCommands(context);
3638
CopilotActions.registerCommands();
3739

src/services/actions/CliActions.ts

Lines changed: 3 additions & 344 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync, writeFileSync } from 'fs';
22
import { Folders } from '../check/Folders';
33
import { commands, Progress, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode';
4-
import { Commands, ContextKeys, WebViewType, WebviewCommand, WorkflowType } from '../../constants';
4+
import { Commands, WebViewType, WebviewCommand, WorkflowType } from '../../constants';
55
import { AppCatalogApp, GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, Subscription } from '../../models';
66
import { Extension } from '../dataType/Extension';
77
import { CliExecuter } from '../executeWrappers/CliCommandExecuter';
@@ -16,9 +16,10 @@ import { parseYoRc } from '../../utils/parseYoRc';
1616
import { parseCliCommand } from '../../utils/parseCliCommand';
1717
import { CertificateActions } from './CertificateActions';
1818
import path = require('path');
19-
import { ActionTreeItem } from '../../providers/ActionTreeDataProvider';
2019
import { getExtensionSettings } from '../../utils/getExtensionSettings';
2120
import * as fs from 'fs';
21+
import { ActionTreeItem } from '../../providers/ActionTreeDataProvider';
22+
2223

2324
export class CliActions {
2425

@@ -43,42 +44,6 @@ export class CliActions {
4344
subscriptions.push(
4445
commands.registerCommand(Commands.pipeline, CliActions.showGenerateWorkflowForm)
4546
);
46-
subscriptions.push(
47-
commands.registerCommand(Commands.deployAppCatalogApp, (node: ActionTreeItem) =>
48-
CliActions.toggleAppDeployed(node, ContextKeys.deployApp, 'deploy')
49-
)
50-
);
51-
subscriptions.push(
52-
commands.registerCommand(Commands.retractAppCatalogApp, (node: ActionTreeItem) =>
53-
CliActions.toggleAppDeployed(node, ContextKeys.retractApp, 'retract')
54-
)
55-
);
56-
subscriptions.push(
57-
commands.registerCommand(Commands.removeAppCatalogApp, CliActions.removeAppCatalogApp)
58-
);
59-
subscriptions.push(
60-
commands.registerCommand(Commands.enableAppCatalogApp, (node: ActionTreeItem) =>
61-
CliActions.toggleAppEnabled(node, ContextKeys.enableApp, 'enable')
62-
)
63-
);
64-
subscriptions.push(
65-
commands.registerCommand(Commands.disableAppCatalogApp, (node: ActionTreeItem) =>
66-
CliActions.toggleAppEnabled(node, ContextKeys.disableApp, 'disable')
67-
)
68-
);
69-
subscriptions.push(
70-
commands.registerCommand(Commands.installAppCatalogApp, (node: ActionTreeItem) =>
71-
CliActions.toggleAppInstalled(node, ContextKeys.installApp, 'install')
72-
)
73-
);
74-
subscriptions.push(
75-
commands.registerCommand(Commands.uninstallAppCatalogApp, (node: ActionTreeItem) =>
76-
CliActions.toggleAppInstalled(node, ContextKeys.uninstallApp, 'uninstall')
77-
)
78-
);
79-
subscriptions.push(
80-
commands.registerCommand(Commands.upgradeAppCatalogApp, CliActions.upgradeAppCatalogApp)
81-
);
8247
subscriptions.push(
8348
commands.registerCommand(Commands.setFormCustomizer, CliActions.setFormCustomizer)
8449
);
@@ -152,312 +117,6 @@ export class CliActions {
152117
}
153118
}
154119

155-
/**
156-
* Deploys or retracts the app in the tenant or site app catalog.
157-
*
158-
* @param node The tree item representing the app to be deployed or retracted.
159-
* @param ctxValue The context value used to identify the action node.
160-
* @param action The action to be performed: 'deploy' or 'retract'.
161-
*/
162-
public static async toggleAppDeployed(node: ActionTreeItem, ctxValue: string, action: 'deploy' | 'retract') {
163-
try {
164-
const actionNode = node.children?.find(child => child.contextValue === ctxValue);
165-
166-
if (!actionNode?.command?.arguments) {
167-
Notifications.error(`Failed to retrieve app details for ${action}.`);
168-
return;
169-
}
170-
171-
const [appID, appTitle, appCatalogUrl, deployed] = actionNode.command.arguments;
172-
173-
if (action === 'deploy' && deployed) {
174-
Notifications.info(`App '${appTitle}' is already deployed.`);
175-
return;
176-
}
177-
178-
if (action === 'retract' && !deployed) {
179-
Notifications.info(`App '${appTitle}' is already retracted.`);
180-
return;
181-
}
182-
183-
const commandOptions: any = {
184-
id: appID,
185-
...(action === 'retract' && { force: true }),
186-
...(appCatalogUrl?.trim() && {
187-
appCatalogScope: 'sitecollection',
188-
appCatalogUrl: appCatalogUrl
189-
})
190-
};
191-
192-
const cliCommand = action === 'deploy' ? 'spo app deploy' : 'spo app retract';
193-
await CliExecuter.execute(cliCommand, 'json', commandOptions);
194-
Notifications.info(`App '${appTitle}' has been successfully ${action === 'deploy' ? 'deployed' : 'retracted'}.`);
195-
196-
// refresh the environmentTreeView
197-
await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView');
198-
} catch (e: any) {
199-
const message = e?.error?.message;
200-
Notifications.error(message);
201-
}
202-
}
203-
204-
/**
205-
* Removes an app from the tenant or site app catalog.
206-
*
207-
* @param node The tree item representing the app to be removed.
208-
*/
209-
public static async removeAppCatalogApp(node: ActionTreeItem) {
210-
try {
211-
const actionNode = node.children?.find(child => child.contextValue === ContextKeys.removeApp);
212-
213-
if (!actionNode?.command?.arguments) {
214-
Notifications.error('Failed to retrieve app details for removal.');
215-
return;
216-
}
217-
218-
const [appID, appTitle, appCatalogUrl] = actionNode.command.arguments;
219-
220-
const shouldRemove = await window.showQuickPick(['Yes', 'No'], {
221-
title: `Are you sure you want to remove the app '${appTitle}' from the app catalog?`,
222-
ignoreFocusOut: true,
223-
canPickMany: false
224-
});
225-
226-
const shouldRemoveAnswer = shouldRemove === 'Yes';
227-
228-
if (!shouldRemoveAnswer) {
229-
return;
230-
}
231-
232-
const commandOptions: any = {
233-
id: appID,
234-
force: true,
235-
...(appCatalogUrl?.trim() && {
236-
appCatalogScope: 'sitecollection',
237-
appCatalogUrl: appCatalogUrl
238-
})
239-
};
240-
241-
await CliExecuter.execute('spo app remove', 'json', commandOptions);
242-
Notifications.info(`App '${appTitle}' has been successfully removed.`);
243-
244-
// refresh the environmentTreeView
245-
await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView');
246-
} catch (e: any) {
247-
const message = e?.error?.message;
248-
Notifications.error(message);
249-
}
250-
}
251-
252-
/**
253-
* Upgrades an app to a newer version available in the app catalog.
254-
*
255-
* @param node The tree item representing the app to be upgraded.
256-
*/
257-
public static async upgradeAppCatalogApp(node: ActionTreeItem) {
258-
try {
259-
const actionNode = node.children?.find(child => child.contextValue === ContextKeys.upgradeApp);
260-
261-
if (!actionNode?.command?.arguments) {
262-
Notifications.error('Failed to retrieve app details for upgrade.');
263-
return;
264-
}
265-
266-
const [appID, appTitle, appCatalogUrl, isTenantApp] = actionNode.command.arguments;
267-
268-
let siteUrl: string = appCatalogUrl;
269-
270-
if (isTenantApp) {
271-
const relativeUrl = await window.showInputBox({
272-
prompt: 'Enter the relative URL of the site to upgrade the app in',
273-
placeHolder: 'e.g., sites/sales or leave blank for root site',
274-
validateInput: (input) => {
275-
const trimmedInput = input.trim();
276-
277-
if (trimmedInput.startsWith('https://')) {
278-
return 'Please provide a relative URL, not an absolute URL.';
279-
}
280-
if (trimmedInput.startsWith('/')) {
281-
return 'Please provide a relative URL without a leading slash.';
282-
}
283-
284-
return undefined;
285-
}
286-
});
287-
288-
if (relativeUrl === undefined) {
289-
Notifications.warning('No site URL provided. App upgrade aborted.');
290-
return;
291-
}
292-
293-
siteUrl = `${new URL(appCatalogUrl).origin}/${relativeUrl.trim()}`;
294-
}
295-
296-
const commandOptions: any = {
297-
id: appID,
298-
...(isTenantApp
299-
? { siteUrl }
300-
: { appCatalogScope: 'sitecollection', siteUrl })
301-
};
302-
303-
await CliExecuter.execute('spo app upgrade', 'json', commandOptions);
304-
Notifications.info(`App '${appTitle}' has been successfully upgraded on site '${siteUrl}'.`);
305-
} catch (e: any) {
306-
const message = e?.message || 'An unexpected error occurred during the app upgrade.';
307-
Notifications.error(message);
308-
}
309-
}
310-
311-
/**
312-
* Enables or disables the app in the tenant or site app catalog.
313-
*
314-
* @param node The tree item representing the app to be deployed or retracted.
315-
* @param ctxValue The context value used to identify the action node.
316-
* @param action The action to be performed: 'enable' or 'disable'.
317-
*/
318-
public static async toggleAppEnabled(node: ActionTreeItem, ctxValue: string, action: 'enable' | 'disable') {
319-
try {
320-
const actionNode = node.children?.find(child => child.contextValue === ctxValue);
321-
322-
if (!actionNode?.command?.arguments) {
323-
Notifications.error(`Failed to retrieve app details for ${action}.`);
324-
return;
325-
}
326-
327-
const [appTitle, appCatalogUrl, isEnabled] = actionNode.command.arguments;
328-
329-
if (action === 'enable' && isEnabled) {
330-
Notifications.info(`App '${appTitle}' is already enabled.`);
331-
return;
332-
}
333-
334-
if (action === 'disable' && !isEnabled) {
335-
Notifications.info(`App '${appTitle}' is already disabled.`);
336-
return;
337-
}
338-
339-
const appProductIdFilter = `Title eq '${appTitle}'`;
340-
const commandOptionsList: any = {
341-
listTitle: 'Apps for SharePoint',
342-
webUrl: appCatalogUrl,
343-
fields: 'Id, Title, IsAppPackageEnabled',
344-
filter: appProductIdFilter
345-
};
346-
347-
const listItemsResponse = await CliExecuter.execute('spo listitem list', 'json', commandOptionsList);
348-
const listItems = JSON.parse(listItemsResponse.stdout || '[]');
349-
350-
if (listItems.length === 0) {
351-
Notifications.error(`App '${appTitle}' not found in the app catalog.`);
352-
return;
353-
}
354-
355-
const appListItemId = listItems[0].Id;
356-
357-
const commandOptionsSet: any = {
358-
listTitle: 'Apps for SharePoint',
359-
id: appListItemId,
360-
webUrl: appCatalogUrl,
361-
IsAppPackageEnabled: !isEnabled ? true : false
362-
};
363-
364-
await CliExecuter.execute('spo listitem set', 'json', commandOptionsSet);
365-
Notifications.info(`App '${appTitle}' has been successfully ${action === 'enable' ? 'enabled' : 'disabled'}.`);
366-
367-
// refresh the environmentTreeView
368-
await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView');
369-
} catch (e: any) {
370-
const message = e?.error?.message;
371-
Notifications.error(message);
372-
}
373-
}
374-
375-
/**
376-
* Installs or uninstalls the app on a specified site.
377-
*
378-
* @param node The tree item representing the app to be installed or uninstalled.
379-
* @param ctxValue The context value used to identify the action node.
380-
* @param action The action to be performed: 'install' or 'uninstall'.
381-
*/
382-
public static async toggleAppInstalled(node: ActionTreeItem, ctxValue: string, action: 'install' | 'uninstall') {
383-
try {
384-
const actionNode = node.children?.find(child => child.contextValue === ctxValue);
385-
386-
if (!actionNode?.command?.arguments) {
387-
Notifications.error(`Failed to retrieve app details for ${action}.`);
388-
return;
389-
}
390-
391-
const [appID, appTitle, appCatalogUrl] = actionNode.command.arguments;
392-
393-
let siteUrl: string | undefined;
394-
if (!appCatalogUrl) {
395-
const relativeUrl = await window.showInputBox({
396-
prompt: 'Enter the relative URL of the site',
397-
ignoreFocusOut: true,
398-
placeHolder: 'e.g., sites/sales or leave blank for root site',
399-
validateInput: (input) => {
400-
const trimmedInput = input.trim();
401-
402-
if (trimmedInput.startsWith('https://')) {
403-
return 'Please provide a relative URL, not an absolute URL.';
404-
}
405-
if (trimmedInput.startsWith('/')) {
406-
return 'Please provide a relative URL without a leading slash.';
407-
}
408-
409-
return undefined;
410-
}
411-
});
412-
413-
if (relativeUrl === undefined) {
414-
Notifications.warning('No site URL provided. Operation aborted.');
415-
return;
416-
}
417-
418-
siteUrl = `${EnvironmentInformation.tenantUrl}/${relativeUrl.trim()}`;
419-
420-
} else {
421-
siteUrl = appCatalogUrl;
422-
}
423-
424-
let forceUninstall = false;
425-
if (action === 'uninstall') {
426-
const confirmForce = await window.showQuickPick(['Yes', 'No'], {
427-
placeHolder: `Are you sure you want to uninstall the app '${appTitle}' from site '${siteUrl}'?`,
428-
ignoreFocusOut: true,
429-
canPickMany: false
430-
});
431-
432-
if (confirmForce === 'Yes') {
433-
forceUninstall = true;
434-
} else {
435-
Notifications.warning('App uninstallation aborted.');
436-
return;
437-
}
438-
}
439-
440-
const commandOptions: any = {
441-
id: appID,
442-
siteUrl: siteUrl,
443-
...(appCatalogUrl && {
444-
appCatalogScope: 'sitecollection'
445-
}),
446-
...(forceUninstall && { force: true })
447-
};
448-
449-
const cliCommand = action === 'install' ? 'spo app install' : 'spo app uninstall';
450-
await CliExecuter.execute(cliCommand, 'json', commandOptions);
451-
Notifications.info(`App '${appTitle}' has been successfully ${action === 'install' ? 'installed' : 'uninstalled'} on site '${siteUrl}'.`);
452-
453-
// refresh the environmentTreeView
454-
await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView');
455-
} catch (e: any) {
456-
const message = e?.error?.message;
457-
Notifications.error(message);
458-
}
459-
}
460-
461120
/**
462121
* Retrieves the tenant-wide extensions from the specified tenant app catalog URL.
463122
* @param tenantAppCatalogUrl The URL of the tenant app catalog.

0 commit comments

Comments
 (0)