From 775060dfcac659c1b2d5ff92f2a231aac3b3726d Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sun, 6 Oct 2024 21:36:22 +0200 Subject: [PATCH] Present solutions deployed per app catalog. Closes #215 --- package.json | 71 ++++++++ src/constants/Commands.ts | 8 + src/constants/ContextKeys.ts | 6 + src/panels/CommandPanel.ts | 144 ++++++++++++---- src/providers/ActionTreeDataProvider.ts | 37 +++- src/services/actions/CliActions.ts | 217 +++++++++++++++++++++++- 6 files changed, 436 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 635697a..4730f6d 100644 --- a/package.json +++ b/package.json @@ -399,6 +399,42 @@ "title": "Refresh Account view", "category": "SharePoint Framework Toolkit", "icon": "$(refresh)" + }, + { + "command": "spfx-toolkit.deployAppCatalogApp", + "title": "Deploy", + "category": "SharePoint Framework Toolkit", + "icon": "$(cloud-upload)" + }, + { + "command": "spfx-toolkit.retractAppCatalogApp", + "title": "Retract", + "category": "SharePoint Framework Toolkit", + "icon": "$(cloud-download)" + }, + { + "command": "spfx-toolkit.removeAppCatalogApp", + "title": "Remove", + "category": "SharePoint Framework Toolkit", + "icon": "$(trash)" + }, + { + "command": "spfx-toolkit.enableAppCatalogApp", + "title": "Enable", + "category": "SharePoint Framework Toolkit", + "icon": "$(check)" + }, + { + "command": "spfx-toolkit.disableAppCatalogApp", + "title": "Disable", + "category": "SharePoint Framework Toolkit", + "icon": "$(circle-slash)" + }, + { + "command": "spfx-toolkit.showMoreActions", + "title": "...", + "category": "SharePoint Framework Toolkit", + "icon": "$(ellipsis)" } ], "menus": { @@ -437,6 +473,34 @@ "command": "spfx-toolkit.logout", "when": "view == pnp-view-account && viewItem == m365Account", "group": "inline@1" + }, + { + "command": "spfx-toolkit.deployAppCatalogApp", + "when": "view == pnp-view-environment && viewItem == pnp.etv.hasAppCatalogApp", + "group": "inline@1" + }, + { + "submenu": "spfx-toolkit.showMoreActions", + "when": "view == pnp-view-environment && viewItem == pnp.etv.hasAppCatalogApp", + "group": "inline@2" + } + ], + "spfx-toolkit.showMoreActions": [ + { + "command": "spfx-toolkit.retractAppCatalogApp", + "group": "actions.more@1" + }, + { + "command": "spfx-toolkit.removeAppCatalogApp", + "group": "actions.more@2" + }, + { + "command": "spfx-toolkit.enableAppCatalogApp", + "group": "actions.more@3" + }, + { + "command": "spfx-toolkit.disableAppCatalogApp", + "group": "actions.more@4" } ], "explorer/context": [ @@ -447,6 +511,13 @@ } ] }, + "submenus": [ + { + "id": "spfx-toolkit.showMoreActions", + "label": "More Actions...", + "icon": "$(ellipsis)" + } + ], "languages": [ { "id": "pnp.project.output", diff --git a/src/constants/Commands.ts b/src/constants/Commands.ts index 61bb1e8..43fe9f4 100644 --- a/src/constants/Commands.ts +++ b/src/constants/Commands.ts @@ -51,4 +51,12 @@ export const Commands = { // Welcome welcome: `${EXTENSION_NAME}.welcome`, + + // App actions + deployAppCatalogApp: `${EXTENSION_NAME}.deployAppCatalogApp`, + retractAppCatalogApp: `${EXTENSION_NAME}.retractAppCatalogApp`, + removeAppCatalogApp: `${EXTENSION_NAME}.removeAppCatalogApp`, + enableAppCatalogApp: `${EXTENSION_NAME}.enableAppCatalogApp`, + disableAppCatalogApp: `${EXTENSION_NAME}.disableAppCatalogApp`, + showMoreActions: `${EXTENSION_NAME}.showMoreActions` }; \ No newline at end of file diff --git a/src/constants/ContextKeys.ts b/src/constants/ContextKeys.ts index 4a1b157..fa7f9b9 100644 --- a/src/constants/ContextKeys.ts +++ b/src/constants/ContextKeys.ts @@ -3,4 +3,10 @@ export const ContextKeys = { isSPFxProject: 'pnp.project.isSPFxProject', isLoggedIn: 'pnp.project.isLoggedIn', hasAppCatalog: 'pnp.project.hasAppCatalog', + hasAppCatalogApp: 'pnp.etv.hasAppCatalogApp', + deployApp: 'pnp.etv.app.deploy', + retractApp: 'pnp.etv.app.retract', + removeApp: 'pnp.etv.app.remove', + enableApp: 'pnp.etv.app.enable', + disableApp: 'pnp.etv.app.disable' }; \ No newline at end of file diff --git a/src/panels/CommandPanel.ts b/src/panels/CommandPanel.ts index b537d75..73194fa 100644 --- a/src/panels/CommandPanel.ts +++ b/src/panels/CommandPanel.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'fs'; -import { commands, workspace, window, Uri } from 'vscode'; +import { commands, workspace, window, Uri, TreeItemCollapsibleState } from 'vscode'; import { Commands, ContextKeys } from '../constants'; import { ActionTreeItem, ActionTreeDataProvider } from '../providers/ActionTreeDataProvider'; import { AuthProvider, M365AuthenticationSession } from '../providers/AuthProvider'; @@ -95,7 +95,7 @@ export class CommandPanel { CommandPanel.helpTreeView(); } - private static refreshAccountTreeView(){ + private static refreshAccountTreeView() { const authInstance = AuthProvider.getInstance(); if (authInstance) { authInstance.getAccount().then(account => CommandPanel.accountTreeView(account)); @@ -110,8 +110,8 @@ export class CommandPanel { commands.executeCommand('setContext', ContextKeys.isLoggedIn, true); - accountCommands.push(new ActionTreeItem(session.account.label, '', { name: 'spo-m365', custom: true }, undefined, undefined, undefined, 'm365Account', [])); - accountCommands[0].children.push(new ActionTreeItem('Entra app registration', '', { name: 'entra-id', custom: true }, undefined, 'vscode.open', Uri.parse(`https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/${session.clientId}`), 'sp-admin-api-url')); + accountCommands.push(new ActionTreeItem(session.account.label, '', { name: 'spo-m365', custom: true }, TreeItemCollapsibleState.Expanded, undefined, undefined, 'm365Account', [])); + accountCommands[0].children?.push(new ActionTreeItem('Entra app registration', '', { name: 'entra-id', custom: true }, undefined, 'vscode.open', Uri.parse(`https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/${session.clientId}`), 'sp-admin-api-url')); const appCatalogUrls = await CliActions.appCatalogUrlsGet(); if (appCatalogUrls?.some) { @@ -121,29 +121,28 @@ export class CommandPanel { const webApiPermissionManagementUrl = `${adminOriginUrl}/_layouts/15/online/AdminHome.aspx#/webApiPermissionManagement`; DebuggerCheck.validateUrl(originUrl); - accountCommands[0].children.push(new ActionTreeItem('SharePoint', '', { name: 'spo-logo', custom: true }, undefined, undefined, undefined, undefined, [ - new ActionTreeItem(originUrl.replace('https://',''), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(originUrl), 'sp-url'), - new ActionTreeItem(adminOriginUrl.replace('https://',''), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(adminOriginUrl), 'sp-admin-url'), + accountCommands[0].children?.push(new ActionTreeItem('SharePoint', '', { name: 'spo-logo', custom: true }, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, [ + new ActionTreeItem(originUrl.replace('https://', ''), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(originUrl), 'sp-url'), + new ActionTreeItem(adminOriginUrl.replace('https://', ''), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(adminOriginUrl), 'sp-admin-url'), new ActionTreeItem(webApiPermissionManagementUrl.replace(`${adminOriginUrl}/_layouts/15/online/AdminHome.aspx#/`, '...'), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(webApiPermissionManagementUrl), 'sp-admin-api-url') ])); const showServiceIncidentList: boolean = getExtensionSettings('showServiceIncidentList', true); if (showServiceIncidentList === true) { const healthInfoList = await CliActions.getTenantHealthInfo(); - if (healthInfoList?.some) - { + if (healthInfoList?.some) { const healthInfoItems: ActionTreeItem[] = []; for (let i = 0; i < healthInfoList.length; i++) { - healthInfoItems.push(new ActionTreeItem(healthInfoList[i].Title, '', { name: 'm365-warning', custom: true } , undefined, 'vscode.open', Uri.parse(healthInfoList[i].Url), 'm365-health-service-url')); + healthInfoItems.push(new ActionTreeItem(healthInfoList[i].Title, '', { name: 'm365-warning', custom: true }, undefined, 'vscode.open', Uri.parse(healthInfoList[i].Url), 'm365-health-service-url')); } if (healthInfoItems.length > 0) { - accountCommands[0].children.push(new ActionTreeItem('Service health incidents', '', { name: 'm365-health', custom: true }, undefined, undefined, undefined, undefined, healthInfoItems)); + accountCommands[0].children?.push(new ActionTreeItem('Service health incidents', '', { name: 'm365-health', custom: true }, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, healthInfoItems)); } } } } - accountCommands[0].children.push(new ActionTreeItem('Sign out', '', { name: 'sign-out', custom: false }, undefined, Commands.logout)); + accountCommands[0].children?.push(new ActionTreeItem('Sign out', '', { name: 'sign-out', custom: false }, undefined, Commands.logout)); CommandPanel.environmentTreeView(appCatalogUrls); } else { EnvironmentInformation.reset(); @@ -156,7 +155,7 @@ export class CommandPanel { window.createTreeView('pnp-view-account', { treeDataProvider: new ActionTreeDataProvider(accountCommands), showCollapseAll: true }); } - private static async refreshEnvironmentTreeView(){ + private static async refreshEnvironmentTreeView() { const appCatalogUrls = await CliActions.appCatalogUrlsGet(); CommandPanel.environmentTreeView(appCatalogUrls); } @@ -169,37 +168,110 @@ export class CommandPanel { } else { const tenantAppCatalogUrl = appCatalogUrls[0]; const origin = new URL(tenantAppCatalogUrl).origin; + commands.executeCommand('setContext', ContextKeys.hasAppCatalog, true); - environmentCommands.push( - new ActionTreeItem('Tenant App Catalog', '', { name: 'spo-logo', custom: true }, undefined, undefined, undefined, undefined, [ - new ActionTreeItem(tenantAppCatalogUrl.replace(origin, '...'), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(tenantAppCatalogUrl), 'sp-app-catalog-url') - ]), - ); + const catalogItems: ActionTreeItem[] = []; const showTenantWideExtensions: boolean = getExtensionSettings('showTenantWideExtensions', true); - if (showTenantWideExtensions === true) { - const tenantWideExtensions = await CliActions.getTenantWideExtensions(tenantAppCatalogUrl); - const tenantWideExtensionsList: ActionTreeItem[] = []; - if (tenantWideExtensions && tenantWideExtensions?.length > 0) { - tenantWideExtensions.forEach((extension) => { - tenantWideExtensionsList.push(new ActionTreeItem(extension.Title, '', { name: 'spo-app', custom: true }, undefined, 'vscode.open', Uri.parse(extension.Url), 'sp-app-catalog-tenant-wide-extensions-url')); - }); - } - else { - tenantWideExtensionsList.push(new ActionTreeItem('none', '', undefined, undefined, undefined, undefined, undefined)); - } + const tenantWideExtensionsNode = new ActionTreeItem('Tenant-wide Extensions', '', { name: 'spo-app-list', custom: true }, TreeItemCollapsibleState.Collapsed, undefined, undefined, 'sp-app-catalog-tenant-wide-extensions', undefined, + async () => { + const tenantWideExtensions = await CliActions.getTenantWideExtensions(tenantAppCatalogUrl); + const tenantWideExtensionsList: ActionTreeItem[] = []; + + if (tenantWideExtensions && tenantWideExtensions.length > 0) { + tenantWideExtensions.forEach((extension) => { + tenantWideExtensionsList.push( + new ActionTreeItem(extension.Title, '', { name: 'spo-app', custom: true }, TreeItemCollapsibleState.None, 'vscode.open', Uri.parse(extension.Url), 'sp-app-catalog-tenant-wide-extensions-url') + ); + }); + } else { + tenantWideExtensionsList.push(new ActionTreeItem('No extension found', '')); + } + + return tenantWideExtensionsList; + } + ); - environmentCommands.push(new ActionTreeItem('Tenant-wide Extensions', '', { name: 'spo-app-list', custom: true }, undefined, undefined, undefined, 'sp-app-catalog-tenant-wide-extensions', tenantWideExtensionsList)); + catalogItems.push(tenantWideExtensionsNode); } + const tenantAppCatalogNode = new ActionTreeItem(tenantAppCatalogUrl.replace(origin, '...'), '', { name: 'globe', custom: false }, TreeItemCollapsibleState.Collapsed, undefined, undefined, 'sp-app-catalog-url', undefined, + async () => { + const tenantAppCatalogApps = await CliActions.getAppCatalogApps(); + const tenantAppCatalogAppsList: ActionTreeItem[] = []; + + if (tenantAppCatalogApps && tenantAppCatalogApps.length > 0) { + const appStoreUrl = `${tenantAppCatalogUrl}/_layouts/15/appStore.aspx`; + + tenantAppCatalogApps.forEach((app) => { + tenantAppCatalogAppsList.push( + new ActionTreeItem(app.Title, '', { name: 'package', custom: false }, undefined, 'vscode.open', Uri.parse(appStoreUrl), ContextKeys.hasAppCatalogApp, + [ + new ActionTreeItem('Deploy', '', undefined, undefined, Commands.deployAppCatalogApp, [app.ID, app.Title, undefined, app.Deployed], ContextKeys.deployApp), + new ActionTreeItem('Retract', '', undefined, undefined, Commands.retractAppCatalogApp, [app.ID, app.Title, undefined, app.Deployed], ContextKeys.retractApp), + new ActionTreeItem('Remove', '', undefined, undefined, Commands.removeAppCatalogApp, [app.ID, app.Title], ContextKeys.removeApp), + new ActionTreeItem('Enable', '', undefined, undefined, Commands.enableAppCatalogApp, [app.Title, tenantAppCatalogUrl, app.Enabled], ContextKeys.enableApp), + new ActionTreeItem('Disable', '', undefined, undefined, Commands.disableAppCatalogApp, [app.Title, tenantAppCatalogUrl, app.Enabled], ContextKeys.disableApp) + ] + ) + ); + }); + } else { + tenantAppCatalogAppsList.push(new ActionTreeItem('No app found', '')); + } + + return tenantAppCatalogAppsList; + } + ); + + catalogItems.push(tenantAppCatalogNode); + + environmentCommands.push( + new ActionTreeItem('Tenant App Catalog', '', { name: 'spo-logo', custom: true }, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, catalogItems), + ); + const siteAppCatalogActionItems: ActionTreeItem[] = []; for (let i = 1; i < appCatalogUrls.length; i++) { - siteAppCatalogActionItems.push(new ActionTreeItem(appCatalogUrls[i].replace(origin, '...'), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(appCatalogUrls[i]), 'sp-app-catalog-url')); + const siteAppCatalogUrl = appCatalogUrls[i]; + + const siteAppCatalogNode = new ActionTreeItem(siteAppCatalogUrl.replace(origin, '...'), '', { name: 'globe', custom: false }, TreeItemCollapsibleState.Collapsed, undefined, undefined, 'sp-app-catalog-url', undefined, + async () => { + const siteAppCatalogApps = await CliActions.getAppCatalogApps(siteAppCatalogUrl); + const siteAppCatalogAppsList: ActionTreeItem[] = []; + + if (siteAppCatalogApps && siteAppCatalogApps.length > 0) { + const appStoreUrl = `${siteAppCatalogUrl}/_layouts/15/appStore.aspx`; + + siteAppCatalogApps.forEach((app) => { + siteAppCatalogAppsList.push( + new ActionTreeItem(app.Title, '', { name: 'package', custom: false }, undefined, 'vscode.open', Uri.parse(appStoreUrl), ContextKeys.hasAppCatalogApp, + [ + new ActionTreeItem('Deploy', '', undefined, undefined, Commands.deployAppCatalogApp, [app.ID, app.Title, siteAppCatalogUrl, app.Deployed], ContextKeys.deployApp), + new ActionTreeItem('Retract', '', undefined, undefined, Commands.retractAppCatalogApp, [app.ID, app.Title, siteAppCatalogUrl, app.Deployed], ContextKeys.retractApp), + new ActionTreeItem('Remove', '', undefined, undefined, Commands.removeAppCatalogApp, [app.ID, app.Title, siteAppCatalogUrl], ContextKeys.removeApp), + new ActionTreeItem('Enable', '', undefined, undefined, Commands.enableAppCatalogApp, [app.Title, siteAppCatalogUrl, app.Enabled], ContextKeys.enableApp), + new ActionTreeItem('Disable', '', undefined, undefined, Commands.disableAppCatalogApp, [app.Title, siteAppCatalogUrl, app.Enabled], ContextKeys.disableApp) + ] + ) + ); + }); + } else { + siteAppCatalogAppsList.push(new ActionTreeItem('No app found', '')); + } + + return siteAppCatalogAppsList; + } + ); + + siteAppCatalogActionItems.push(siteAppCatalogNode); } + if (siteAppCatalogActionItems.length > 0) { - environmentCommands.push(new ActionTreeItem('Site App Catalogs', '', { name: 'spo-logo', custom: true }, undefined, undefined, undefined, undefined, siteAppCatalogActionItems)); + environmentCommands.push( + new ActionTreeItem('Site App Catalogs', '', { name: 'spo-logo', custom: true }, TreeItemCollapsibleState.Collapsed, undefined, undefined, undefined, siteAppCatalogActionItems) + ); } } @@ -245,7 +317,7 @@ export class CommandPanel { private static helpTreeView() { const helpCommands: ActionTreeItem[] = [ - new ActionTreeItem('Docs & Learning', '', undefined, undefined, undefined, undefined, undefined, [ + new ActionTreeItem('Docs & Learning', '', undefined, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, [ new ActionTreeItem('Overview of the SharePoint Framework', '', { name: 'book', custom: false }, undefined, 'vscode.open', Uri.parse('https://learn.microsoft.com/en-us/sharepoint/dev/spfx/sharepoint-framework-overview')), new ActionTreeItem('Overview of Viva Connections Extensibility', '', { name: 'book', custom: false }, undefined, 'vscode.open', Uri.parse('https://learn.microsoft.com/en-us/sharepoint/dev/spfx/viva/overview-viva-connections')), new ActionTreeItem('Overview of Microsoft Graph', '', { name: 'book', custom: false }, undefined, 'vscode.open', Uri.parse('https://learn.microsoft.com/en-us/graph/overview?view=graph-rest-1.0')), @@ -253,7 +325,7 @@ export class CommandPanel { new ActionTreeItem('Learning path: Extend Microsoft Viva Connections', '', { name: 'mortar-board', custom: false }, undefined, 'vscode.open', Uri.parse('https://learn.microsoft.com/en-us/training/paths/m365-extend-viva-connections/')), new ActionTreeItem('Learning path: Microsoft Graph Fundamentals', '', { name: 'mortar-board', custom: false }, undefined, 'vscode.open', Uri.parse('https://learn.microsoft.com/en-us/training/paths/m365-msgraph-fundamentals/')) ]), - new ActionTreeItem('Resources & Tooling', '', undefined, undefined, undefined, undefined, undefined, [ + new ActionTreeItem('Resources & Tooling', '', undefined, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, [ new ActionTreeItem('Microsoft Graph Explorer', '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse('https://developer.microsoft.com/en-us/graph/graph-explorer')), new ActionTreeItem('Teams Toolkit', '', { name: 'tools', custom: false }, undefined, 'vscode.open', Uri.parse('https://marketplace.visualstudio.com/items?itemName=TeamsDevApp.ms-teams-vscode-extension')), new ActionTreeItem('Adaptive Card Previewer', '', { name: 'tools', custom: false }, undefined, 'vscode.open', Uri.parse('https://marketplace.visualstudio.com/items?itemName=TeamsDevApp.vscode-adaptive-cards')), @@ -262,11 +334,11 @@ export class CommandPanel { new ActionTreeItem('Join the Microsoft 365 Developer Program', '', { name: 'star-empty', custom: false }, undefined, 'vscode.open', Uri.parse('https://developer.microsoft.com/en-us/microsoft-365/dev-program')), new ActionTreeItem('Sample Solution Gallery', '', { name: 'library', custom: false }, undefined, 'vscode.open', Uri.parse('https://adoption.microsoft.com/en-us/sample-solution-gallery/')) ]), - new ActionTreeItem('Community', '', undefined, undefined, undefined, undefined, undefined, [ + new ActionTreeItem('Community', '', undefined, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, [ new ActionTreeItem('Microsoft 365 & Power Platform Community Home', '', { name: 'organization', custom: false }, undefined, 'vscode.open', Uri.parse('https://pnp.github.io/')), new ActionTreeItem('Join the Microsoft 365 & Power Platform Community Discord Server', '', { name: 'feedback', custom: false }, undefined, 'vscode.open', Uri.parse('https://aka.ms/community/discord')) ]), - new ActionTreeItem('Support', '', undefined, undefined, undefined, undefined, undefined, [ + new ActionTreeItem('Support', '', undefined, TreeItemCollapsibleState.Expanded, undefined, undefined, undefined, [ new ActionTreeItem('Wiki', '', { name: 'question', custom: false }, undefined, 'vscode.open', Uri.parse('https://github.com/pnp/vscode-viva/wiki')), new ActionTreeItem('Report an issue', '', { name: 'github', custom: false }, undefined, 'vscode.open', Uri.parse('https://github.com/pnp/vscode-viva/issues/new/choose')), new ActionTreeItem('Start Walkthrough', '', { name: 'info', custom: false }, undefined, Commands.welcome) diff --git a/src/providers/ActionTreeDataProvider.ts b/src/providers/ActionTreeDataProvider.ts index 30326a5..01821f5 100644 --- a/src/providers/ActionTreeDataProvider.ts +++ b/src/providers/ActionTreeDataProvider.ts @@ -1,8 +1,9 @@ -import { Event, ProviderResult, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Event, ProviderResult, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, EventEmitter } from 'vscode'; export class ActionTreeDataProvider implements TreeDataProvider { - onDidChangeTreeData?: Event | undefined; + private _onDidChangeTreeData: EventEmitter = new EventEmitter(); + readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; actions: ActionTreeItem[]; @@ -15,26 +16,32 @@ export class ActionTreeDataProvider implements TreeDataProvider { } getChildren(element?: ActionTreeItem | undefined): ProviderResult { - return element && (element as any).children ? Promise.resolve((element as any).children) : Promise.resolve(this.actions); + if (element) { + return element.fetchChildren(); + } + return this.actions; + } + + refresh(element?: ActionTreeItem): void { + this._onDidChangeTreeData.fire(element); } } export class ActionTreeItem extends TreeItem { + children: ActionTreeItem[] | undefined; + loadChildren: (() => Promise) | undefined; - children: ActionTreeItem[] = []; - - constructor(label: string, description?: string, image?: { name: string; custom: boolean }, collapsibleState?: TreeItemCollapsibleState, command?: any, args?: any, contextValue?: string, children?: ActionTreeItem[]) { - super(label, children ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None); + constructor(label: string, description?: string, image?: { name: string; custom: boolean }, collapsibleState?: TreeItemCollapsibleState, command?: any, args?: any, contextValue?: string, children?: ActionTreeItem[], loadChildren?: () => Promise) { + super(label, collapsibleState); this.label = label; this.description = description; - this.iconPath = image ? new ThemeIcon(image.name) : undefined; this.command = command ? { command: command, title: label, - arguments: [args] + arguments: Array.isArray(args) ? args : [args] } : undefined; this.contextValue = contextValue; @@ -42,5 +49,17 @@ export class ActionTreeItem extends TreeItem { if (children) { this.children = children; } + else if (collapsibleState === TreeItemCollapsibleState.Collapsed) { + this.children = undefined; + } + + this.loadChildren = loadChildren; + } + + async fetchChildren() { + if (this.loadChildren && !this.children) { + this.children = await this.loadChildren(); + } + return this.children || []; } } \ No newline at end of file diff --git a/src/services/actions/CliActions.ts b/src/services/actions/CliActions.ts index 89b0d15..682185d 100644 --- a/src/services/actions/CliActions.ts +++ b/src/services/actions/CliActions.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync } from 'fs'; import { Folders } from '../check/Folders'; import { commands, Progress, ProgressLocation, Uri, window, workspace } from 'vscode'; -import { Commands, WebViewType, WebviewCommand, WorkflowType } from '../../constants'; +import { Commands, ContextKeys, WebViewType, WebviewCommand, WorkflowType } from '../../constants'; import { GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, Subscription } from '../../models'; import { Extension } from '../dataType/Extension'; import { CliExecuter } from '../executeWrappers/CliCommandExecuter'; @@ -15,8 +15,16 @@ import { PnPWebview } from '../../webview/PnPWebview'; import { parseYoRc } from '../../utils/parseYoRc'; import { CertificateActions } from './CertificateActions'; import path = require('path'); +import { ActionTreeItem } from '../../providers/ActionTreeDataProvider'; +interface AppCatalogApp { + ID: string; + Title: string; + Deployed: boolean; + Enabled: boolean; +} + export class CliActions { public static registerCommands() { @@ -40,6 +48,29 @@ export class CliActions { subscriptions.push( commands.registerCommand(Commands.pipeline, CliActions.showGenerateWorkflowForm) ); + subscriptions.push( + commands.registerCommand(Commands.deployAppCatalogApp, (node: ActionTreeItem) => + CliActions.toggleAppDeployed(node, ContextKeys.deployApp, 'deploy') + ) + ); + subscriptions.push( + commands.registerCommand(Commands.retractAppCatalogApp, (node: ActionTreeItem) => + CliActions.toggleAppDeployed(node, ContextKeys.retractApp, 'retract') + ) + ); + subscriptions.push( + commands.registerCommand(Commands.removeAppCatalogApp, CliActions.removeAppCatalogApp) + ); + subscriptions.push( + commands.registerCommand(Commands.enableAppCatalogApp, (node: ActionTreeItem) => + CliActions.toggleAppEnabled(node, ContextKeys.enableApp, 'enable') + ) + ); + subscriptions.push( + commands.registerCommand(Commands.disableAppCatalogApp, (node: ActionTreeItem) => + CliActions.toggleAppEnabled(node, ContextKeys.disableApp, 'disable') + ) + ); } /** @@ -58,7 +89,7 @@ export class CliActions { if (siteAppCatalogs) { const siteAppCatalogsJson: SiteAppCatalog[] = JSON.parse(siteAppCatalogs); - siteAppCatalogsJson.forEach((siteAppCatalog) => appCatalogUrls.push(`${siteAppCatalog.AbsoluteUrl}/AppCatalog`)); + siteAppCatalogsJson.forEach((siteAppCatalog) => appCatalogUrls.push(`${siteAppCatalog.AbsoluteUrl}`)); } EnvironmentInformation.appCatalogUrls = appCatalogUrls ? appCatalogUrls : undefined; @@ -68,6 +99,188 @@ export class CliActions { } } + /** +* Retrieves the list of apps deployed at the tenant or site app catalog. +* +* @param appCatalogUrl The URL of the tenant or site app catalog. +* @returns A promise that resolves to an array of objects containing the ID, Title, Deployed, and Enabled status of each app. +*/ + public static async getAppCatalogApps(appCatalogUrl?: string): Promise { + try { + const commandOptions: any = appCatalogUrl && appCatalogUrl.trim() !== '' ? { + appCatalogScope: 'sitecollection', + appCatalogUrl: appCatalogUrl + } : {}; + + const response = (await CliExecuter.execute('spo app list', 'json', commandOptions)); + const apps = response?.stdout || '[]'; + + const appsJson: any[] = JSON.parse(apps); + const appList = appsJson.map(({ ID, Title, Deployed, IsEnabled }) => { + return { + ID, + Title, + Deployed, + Enabled: IsEnabled + }; + }); + + return appList; + } catch (e: any) { + const message = e?.error?.message; + Notifications.error(message); + } + } + + /** + * Deploys or retracts the app in the tenant or site app catalog. + * + * @param node The tree item representing the app to be deployed or retracted. + * @param ctxValue The context value used to identify the action node. + * @param action The action to be performed: 'deploy' or 'retract'. + */ + public static async toggleAppDeployed(node: ActionTreeItem, ctxValue: string, action: 'deploy' | 'retract') { + try { + const actionNode = node.children?.find(child => child.contextValue === ctxValue); + + if (!actionNode?.command?.arguments) { + Notifications.error(`Failed to retrieve app details for ${action}.`); + return; + } + + const [appID, appTitle, appCatalogUrl, deployed] = actionNode.command.arguments; + + if (action === 'deploy' && deployed) { + Notifications.info(`App '${appTitle}' is already deployed.`); + return; + } + + if (action === 'retract' && !deployed) { + Notifications.info(`App '${appTitle}' is already retracted.`); + return; + } + + const commandOptions: any = { + id: appID, + ...(action === 'retract' && { force: true }), + ...(appCatalogUrl?.trim() && { + appCatalogScope: 'sitecollection', + appCatalogUrl: appCatalogUrl + }) + }; + + const cliCommand = action === 'deploy' ? 'spo app deploy' : 'spo app retract'; + await CliExecuter.execute(cliCommand, 'json', commandOptions); + Notifications.info(`App '${appTitle}' has been successfully ${action === 'deploy' ? 'deployed' : 'retracted'}.`); + + // refresh the environmentTreeView + await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView'); + } catch (e: any) { + const message = e?.error?.message; + Notifications.error(message); + } + } + + /** + * Removes an app from the tenant or site app catalog. + * + * @param node The tree item representing the app to be removed. + */ + public static async removeAppCatalogApp(node: ActionTreeItem) { + try { + const actionNode = node.children?.find(child => child.contextValue === 'sp-app-remove-tenant'); + + if (!actionNode?.command?.arguments) { + Notifications.error('Failed to retrieve app details for removal.'); + return; + } + + const [appID, appTitle, appCatalogUrl] = actionNode.command.arguments; + + const commandOptions: any = { + id: appID, + force: true, + ...(appCatalogUrl?.trim() && { + appCatalogScope: 'sitecollection', + appCatalogUrl: appCatalogUrl + }) + }; + + await CliExecuter.execute('spo app remove', 'json', commandOptions); + Notifications.info(`App '${appTitle}' has been successfully removed.`); + + // refresh the environmentTreeView + await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView'); + } catch (e: any) { + const message = e?.error?.message; + Notifications.error(message); + } + } + + /** + * Enables or disables the app in the tenant or site app catalog. + * + * @param node The tree item representing the app to be deployed or retracted. + * @param ctxValue The context value used to identify the action node. + * @param action The action to be performed: 'enable' or 'disable'. + */ + public static async toggleAppEnabled(node: ActionTreeItem, ctxValue: string, action: 'enable' | 'disable') { + try { + const actionNode = node.children?.find(child => child.contextValue === ctxValue); + + if (!actionNode?.command?.arguments) { + Notifications.error(`Failed to retrieve app details for ${action}.`); + return; + } + + const [appTitle, appCatalogUrl, isEnabled] = actionNode.command.arguments; + + if (action === 'enable' && isEnabled) { + Notifications.info(`App '${appTitle}' is already enabled.`); + return; + } + + if (action === 'disable' && !isEnabled) { + Notifications.info(`App '${appTitle}' is already disabled.`); + return; + } + + const appProductIdFilter = `Title eq '${appTitle}'`; + const commandOptionsList: any = { + listTitle: 'Apps for SharePoint', + webUrl: appCatalogUrl, + fields: 'Id, Title, IsAppPackageEnabled', + filter: appProductIdFilter + }; + + const listItemsResponse = await CliExecuter.execute('spo listitem list', 'json', commandOptionsList); + const listItems = JSON.parse(listItemsResponse.stdout || '[]'); + + if (listItems.length === 0) { + Notifications.error(`App '${appTitle}' not found in the app catalog.`); + return; + } + + const appListItemId = listItems[0].Id; + + const commandOptionsSet: any = { + listTitle: 'Apps for SharePoint', + id: appListItemId, + webUrl: appCatalogUrl, + IsAppPackageEnabled: !isEnabled ? true : false + }; + + await CliExecuter.execute('spo listitem set', 'json', commandOptionsSet); + Notifications.info(`App '${appTitle}' has been successfully ${action === 'enable' ? 'enabled' : 'disabled'}.`); + + // refresh the environmentTreeView + await commands.executeCommand('spfx-toolkit.refreshAppCatalogTreeView'); + } catch (e: any) { + const message = e?.error?.message; + Notifications.error(message); + } + } + /** * Retrieves the tenant-wide extensions from the specified tenant app catalog URL. * @param tenantAppCatalogUrl The URL of the tenant app catalog.