diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe161e..92c26bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [4.0.0] - 2024-09-08 + +- Updated Sign in method +- Updated SPFx Intro and workspace setup walkthrough steps +- Updated readme assets +- Added form to create Entra App Registration required for sign in +- Added Extension filter dropdown to the sample gallery +- Updated account view to show the Entra App Registration +- Updated terminal usage to unified approach +- Updated release workflows +- Added settings to show and hide health incidents and tenant wide extensions + ## [3.4.0] - 2024-07-18 - Improved welcome experience diff --git a/README.md b/README.md index e973784..cf37ad6 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ In case you do not have all dependencies installed, or some are incorrect versio [Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/5.1-Validate-and-set-up-a-local-workspace) -### 3️⃣ Don't Start from scratch. Reuse an SPFx (web part or extension) or ACE sample +### 3️⃣ Don't Start from scratch. Reuse an SPFx web part or extension or ACE sample You may kick-start your development with a new project based on an existing ACE or SPFx web part or extension with a click of a button. All of the provided samples are powered by [PnP Samples repositories](https://pnp.github.io/sp-dev-fx-webparts/samples/type/). @@ -90,7 +90,6 @@ Switch between the list and grid view and don't worry about the size of your VS ![Sample gallery is responsive](./assets/images/samples-responsive.png) Check out how easy it is to create a new project based on a existing sample 👇. - ![Create project based on web part sample](./assets/images/sample-gallery.gif) [Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/5.2-Scaffolding#2-dont-start-from-scratch---sample-galleries) @@ -119,33 +118,45 @@ Install additional dependencies with a single click straight from the scaffoldin [Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/5.2-Scaffolding#1-scaffold-a-new-spfx-project) -### 6️⃣ Login to your tenant & retrieve environment details - -![tenant info](./assets/images/tenant-info.png) +### 6️⃣ Sign in to your tenant & retrieve environment details -The extension allows you to login to your Microsoft 365 tenant using CLI for Microsoft 365. +The extension allows you to sign in to your Microsoft 365 tenant using CLI for Microsoft 365. ![login](./assets/images/login.png) -Thanks to that the extension will retrieve helpful URLs from your tenant like: +SPFx Toolkit needs and Entra App Registration to be able to sign in to your tenant. You may either use an existing app registration or create a new one with a single click using a dedicated form. + +![sign in options](./assets/images/sign-in-options.png) + +SPFx Toolkit will guide you through the process of creating a new app registration either manually by providing step-by-step guidance or automatically by creating the app registration for you. + +![entra app reg form](./assets/images/sign-in-entra-app-reg-form.png) + +![app registration](./assets/images/sign-in.gif) + +If you already have an Entra App Registration you may use it to sign in to your tenant by providing the Client Id and Tenant Id. + +![app registration](./assets/images/sign-in-existing-app.gif) + +Thanks to that the extension will retrieve helpful URLs from your tenant like link to: -- link to SharePoint main site -- link to SharePoint admin site -- link to SharePoint web API permission management page +- SharePoint main site +- SharePoint admin site +- SharePoint web API permission management page Additionally, the extension will check and retrieve tenant service health incidents that are currently happening in your tenant so that you gain quick insights on your tenant health. ![tenant details](./assets/images/tenant-links.png) -After successful login an additional view is presented that shows list links to app catalogs available in the tenant, both tenant-level and all site-level app catalogs. Additionally it will show you all tenant-wide extensions installed on your tenant with. +After successful sign in an additional view is presented that shows list links to app catalogs available in the tenant, both tenant-level and all site-level app catalogs. Additionally it will show you all tenant-wide extensions installed on your tenant with. ![tenant details](./assets/images/app-catalog-list.png) -Login-in is also required for some actions to work properly like the deploy action which allows you to upload of the .sppkg file to the tenant or site-level App Catalog. +Using the extension settings you may choose show or hide the tenant-wide extensions list and tenant health incidents list. -Additionally, when an SPFx project is opened the extension will check serve.json file and suggest updating it to set the properties based on the currently logged-in tenant. +![settings](./assets/images/settings.png) -![validate serve](./assets/images/validate-serve-config.png) +Sign-in is also required for some actions to work properly like the deploy action which allows you to upload of the .sppkg file to the tenant or site-level App Catalog. [Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/5.3-Login-to-your-tenant-&-retrieve-environment-details) diff --git a/assets/fonts/fabric-icons.woff b/assets/fonts/fabric-icons.woff index 386e4a9..1af9704 100644 Binary files a/assets/fonts/fabric-icons.woff and b/assets/fonts/fabric-icons.woff differ diff --git a/assets/images/actions.png b/assets/images/actions.png index 0ea9bfe..4f61972 100644 Binary files a/assets/images/actions.png and b/assets/images/actions.png differ diff --git a/assets/images/add-component.png b/assets/images/add-component.png index 42c63cd..48a2955 100644 Binary files a/assets/images/add-component.png and b/assets/images/add-component.png differ diff --git a/assets/images/app-catalog-list.png b/assets/images/app-catalog-list.png index ab0cfa7..db8edfb 100644 Binary files a/assets/images/app-catalog-list.png and b/assets/images/app-catalog-list.png differ diff --git a/assets/images/deploy.png b/assets/images/deploy.png index e448666..b5ade9e 100644 Binary files a/assets/images/deploy.png and b/assets/images/deploy.png differ diff --git a/assets/images/grant-permissions.png b/assets/images/grant-permissions.png index 137a01d..42a27cb 100644 Binary files a/assets/images/grant-permissions.png and b/assets/images/grant-permissions.png differ diff --git a/assets/images/help-and-feedback.png b/assets/images/help-and-feedback.png index d0ae291..31aca48 100644 Binary files a/assets/images/help-and-feedback.png and b/assets/images/help-and-feedback.png differ diff --git a/assets/images/login.png b/assets/images/login.png index 19dc197..aa9018a 100644 Binary files a/assets/images/login.png and b/assets/images/login.png differ diff --git a/assets/images/rename.png b/assets/images/rename.png index 85a50d9..54d61c1 100644 Binary files a/assets/images/rename.png and b/assets/images/rename.png differ diff --git a/assets/images/sample-gallery.gif b/assets/images/sample-gallery.gif index f16e72f..124c905 100644 Binary files a/assets/images/sample-gallery.gif and b/assets/images/sample-gallery.gif differ diff --git a/assets/images/samples-responsive.png b/assets/images/samples-responsive.png index c944bfa..a9b90cc 100644 Binary files a/assets/images/samples-responsive.png and b/assets/images/samples-responsive.png differ diff --git a/assets/images/samples.png b/assets/images/samples.png index 1f33f32..3ab849a 100644 Binary files a/assets/images/samples.png and b/assets/images/samples.png differ diff --git a/assets/images/scaffolding-form.gif b/assets/images/scaffolding-form.gif index 307e770..f800e0c 100644 Binary files a/assets/images/scaffolding-form.gif and b/assets/images/scaffolding-form.gif differ diff --git a/assets/images/scaffolding.png b/assets/images/scaffolding.png index b562dcf..34e3405 100644 Binary files a/assets/images/scaffolding.png and b/assets/images/scaffolding.png differ diff --git a/assets/images/settings.png b/assets/images/settings.png new file mode 100644 index 0000000..c2a9da4 Binary files /dev/null and b/assets/images/settings.png differ diff --git a/assets/images/sign-in-entra-app-reg-form.png b/assets/images/sign-in-entra-app-reg-form.png new file mode 100644 index 0000000..8864d3b Binary files /dev/null and b/assets/images/sign-in-entra-app-reg-form.png differ diff --git a/assets/images/sign-in-existing-app.gif b/assets/images/sign-in-existing-app.gif new file mode 100644 index 0000000..26bfcfa Binary files /dev/null and b/assets/images/sign-in-existing-app.gif differ diff --git a/assets/images/sign-in-options.png b/assets/images/sign-in-options.png new file mode 100644 index 0000000..35b645c Binary files /dev/null and b/assets/images/sign-in-options.png differ diff --git a/assets/images/sign-in.gif b/assets/images/sign-in.gif new file mode 100644 index 0000000..ca29f58 Binary files /dev/null and b/assets/images/sign-in.gif differ diff --git a/assets/images/start.png b/assets/images/start.png index 634b91e..4e989f8 100644 Binary files a/assets/images/start.png and b/assets/images/start.png differ diff --git a/assets/images/tenant-info.png b/assets/images/tenant-info.png deleted file mode 100644 index b7f2bac..0000000 Binary files a/assets/images/tenant-info.png and /dev/null differ diff --git a/assets/images/tenant-links.png b/assets/images/tenant-links.png index f3ec8ba..6110a41 100644 Binary files a/assets/images/tenant-links.png and b/assets/images/tenant-links.png differ diff --git a/assets/images/upgrade-project.png b/assets/images/upgrade-project.png index e75f212..540cbab 100644 Binary files a/assets/images/upgrade-project.png and b/assets/images/upgrade-project.png differ diff --git a/assets/images/validate-dependency.png b/assets/images/validate-dependency.png index 26d87bb..2ab8a36 100644 Binary files a/assets/images/validate-dependency.png and b/assets/images/validate-dependency.png differ diff --git a/assets/images/validate-project.png b/assets/images/validate-project.png index 51bd955..eb532ab 100644 Binary files a/assets/images/validate-project.png and b/assets/images/validate-project.png differ diff --git a/assets/images/validate-serve-config.png b/assets/images/validate-serve-config.png deleted file mode 100644 index 79cefa3..0000000 Binary files a/assets/images/validate-serve-config.png and /dev/null differ diff --git a/assets/images/walkthrough.png b/assets/images/walkthrough.png index ab2e4b5..f5fc752 100644 Binary files a/assets/images/walkthrough.png and b/assets/images/walkthrough.png differ diff --git a/assets/images/welcome-experience.png b/assets/images/welcome-experience.png index bc64488..771b908 100644 Binary files a/assets/images/welcome-experience.png and b/assets/images/welcome-experience.png differ diff --git a/assets/walkthrough/spfx-intro.md b/assets/walkthrough/spfx-intro.md index 3aa2740..f734220 100644 --- a/assets/walkthrough/spfx-intro.md +++ b/assets/walkthrough/spfx-intro.md @@ -1,7 +1,35 @@ ## SharePoint Framework -With SharePoint Framework (SPFx), you can use modern web technologies and tools in your preferred development environment to build productive experiences and apps that are responsive and mobile-ready allowing you to create solutions to extend SharePoint, Microsoft Teams, Microsoft Viva Connections, Outlook and Microsoft365.com. +With SharePoint Framework (SPFx), you can use modern web technologies and tools in your preferred development environment to build productive experiences and apps that are responsive and mobile-ready allowing you to create solutions to extend SharePoint, Microsoft Teams, Microsoft Viva Connections, Outlook and Microsoft365.com. This extensibility model allows you to write once and reuse your solutions in multiple Microsoft 365 applications with exactly the same code base. + +The following are some of the key features included as part of the SPFx: + +- It runs in the context of the current user and connection in the browser. There are no iFrames for the customization (JavaScript is - embedded directly to the page). +- The controls are rendered in the normal page DOM. +- The controls are responsive and accessible by nature. +- It enables the developer to access the lifecycle in addition to render, load, serialize and deserialize, configuration changes, and more. +- It's framework-agnostic. You can use any JavaScript framework that you like including, but not limited to, React, Handlebars, Knockout, Angular, and Vue.js. +- The developer toolchain is based on popular open-source client development tools such as NPM, TypeScript, Yeoman, webpack, and gulp. +Performance is reliable. +- End users can use SPFx client-side solutions that are approved by the tenant administrators (or their delegates) on all sites, including self-service team, group, or personal sites. +- SPFx web parts can be added to both classic and modern pages. +- SPFx solutions can be used to extend Microsoft Teams. +- SPFx can be used to extend Microsoft Viva Connections. +- SPFx can be use to extend Outlook and Office 365 app (Office) + +With SharePoint Framework you may create client-side web parts, extensions, and adaptive cards. + +- Web pats are are controls that appear inside a SharePoint page and execute client-side in the browser. They're the building blocks of pages that appear on a SharePoint site. You can build client-side web parts using modern client-side development tools and the SharePoint workbench (a development test surface). You can deploy your client-side web parts to both modern pages and classic web part pages in Microsoft 365 tenants. +- Extensions allows you to extend the SharePoint user experience. With SPFx Extensions, you can customize more facets of the SharePoint experience, including notification areas, toolbars, list data views, and forms. SPFx Extensions are available in all Microsoft 365 subscriptions for production usage.SPFx Extensions enable you to extend the SharePoint user experience within modern pages and document libraries, while using the familiar SPFx tools and libraries for client-side development. Specifically, the SPFx includes four extension types: + + - Application Customizers: Adds scripts to the page, and accesses well-known HTML element placeholders and extends them with custom renderings. + - Field Customizers: Provides modified views to data for fields within a list. + - Command Sets: Extends the SharePoint command surfaces to add new actions, and provides client-side code that you can use to implement behaviors. + - Form Customizer: Provides a way to assoicate and override default new, edit and view form experience of list and libraries with custom forms by associating component to content type. + +- Library components enables you to have independently versioned and deployed code served automatically for the SharePoint Framework components with a deployment through an app catalog. Library components provide you an alternative option to create shared code, which can be then used and referenced cross all the components in the tenant. +- Adaptive Cards Extensions allows you to extend Viva Connections dashboard with your own custom functionalities and visualizations Go over the [overview of the SharePoint Framework](https://learn.microsoft.com/en-us/sharepoint/dev/spfx/sharepoint-framework-overview) to find out more. diff --git a/assets/walkthrough/tenant-details.md b/assets/walkthrough/tenant-details.md index bdeee54..8dabfe4 100644 --- a/assets/walkthrough/tenant-details.md +++ b/assets/walkthrough/tenant-details.md @@ -1,29 +1,35 @@ -## Login to your tenant & retrieve environment details +## Sign in to your tenant & retrieve environment details -![tenant info](../images/tenant-info.png) - -The extension allows you to login to your Microsoft 365 tenant using CLI for Microsoft 365. +The extension allows you to sign in to your Microsoft 365 tenant using CLI for Microsoft 365. ![login](../images/login.png) -Thanks to that the extension will retrieve helpful URLs from your tenant like: +SPFx Toolkit needs and Entra App Registration to be able to sign in to your tenant. You may either use an existing app registration or create a new one with a single click using a dedicated form. SPFx Toolkit will guide you through the process of creating a new app registration either manually by providing step-by-step guidance or automatically by creating the app registration for you. + +![app registration](../images/sign-in.gif) + +If you already have an Entra App Registration you may use it to sign in to your tenant by providing the Client Id and Tenant Id. + +![app registration](../images/sign-in-existing-app.gif) + +Thanks to that the extension will retrieve helpful URLs from your tenant like link to: -- link to SharePoint main site -- link to SharePoint admin site -- link to SharePoint web API permission management page +- SharePoint main site +- SharePoint admin site +- SharePoint web API permission management page Additionally, the extension will check and retrieve tenant service health incidents that are currently happening in your tenant so that you gain quick insights on your tenant health. ![tenant details](../images/tenant-links.png) -After successful login an additional view is presented that shows list links to app catalogs available in the tenant, both tenant-level and all site-level app catalogs. Additionally it will show you all tenant-wide extensions installed on your tenant with. +After successful sign in an additional view is presented that shows list links to app catalogs available in the tenant, both tenant-level and all site-level app catalogs. Additionally it will show you all tenant-wide extensions installed on your tenant with. ![tenant details](../images/app-catalog-list.png) -Login-in is also required for some actions to work properly like the deploy action which allows you to upload of the .sppkg file to the tenant or site-level App Catalog. +Using the extension settings you may choose show or hide the tenant-wide extensions list and tenant health incidents list. -Additionally, when an SPFx project is opened the extension will check serve.json file and suggest updating it to set the properties based on the currently logged-in tenant. +![settings](../images/settings.png) -![validate serve](../images/validate-serve-config.png) +Sign-in is also required for some actions to work properly like the deploy action which allows you to upload of the .sppkg file to the tenant or site-level App Catalog. [Check out our docs for more details](https://github.com/pnp/vscode-viva/wiki/5.3-Login-to-your-tenant-&-retrieve-environment-details) \ No newline at end of file diff --git a/assets/walkthrough/validate-local-setup.md b/assets/walkthrough/validate-local-setup.md new file mode 100644 index 0000000..27536b2 --- /dev/null +++ b/assets/walkthrough/validate-local-setup.md @@ -0,0 +1,20 @@ +## Set up your SharePoint Framework development environment + +To build and deploy client-side web parts, extensions, or adaptive cards using the SharePoint Framework, you will need to setup your development environment. SPFx requires: + +- Node.js +- Yeoman +- Gulp +- Yeoman SharePoint generator + +If you have Node.js you may validate and install these dependencies by running the following commands in your terminal + +```sh +npm install gulp-cli yo @microsoft/generator-sharepoint --global +``` + +or you may use SPFx toolkit to validate and install these dependencies for you. + +![setup local workspace](../images/validate-dependency.png) + +Check out the [docs for more details](https://learn.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-development-environment) on the required node and dependency versions. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 71422cb..f5760ed 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "viva-connections-toolkit", - "version": "3.4.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "viva-connections-toolkit", - "version": "3.4.0", + "version": "4.0.0", "license": "MIT", "dependencies": { "@pnp/cli-microsoft365": "6.11.0", diff --git a/package.json b/package.json index 8b64fd5..2693356 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "viva-connections-toolkit", "displayName": "SharePoint Framework Toolkit", "description": "SharePoint Framework Toolkit aims to boost your productivity in developing and managing SharePoint Framework solutions helping at every stage of your development flow, from setting up your development workspace to deploying a solution straight to your tenant without the need to leave VS Code and now even create a CI/CD pipeline to introduce automate deployment of your app. This toolkit is provided by the community.", - "version": "3.4.0", + "version": "4.0.0", "publisher": "m365pnp", "preview": false, "homepage": "https://github.com/pnp/vscode-viva", @@ -74,23 +74,22 @@ { "id": "spfx-toolkit-intro", "title": "SharePoint Framework Toolkit Introduction", - "description": "Learn how to boost your productivity in developing and managing SharePoint Framework solutions.", + "description": "Start your journey wih SharePoint Framework and learn how to boost your productivity in developing and managing SharePoint Framework solutions.", "steps": [ { "id": "intro-to-spfx", "title": "Introduction to SharePoint Framework", - "description": "Set up your Microsoft 365 tenant \n[Join the Microsoft 365 Developer Program](https://developer.microsoft.com/en-us/microsoft-365/dev-program)", + "description": "Learn SharePoint Framework and set up your Microsoft 365 tenant \n[Join the Microsoft 365 Developer Program](https://developer.microsoft.com/en-us/microsoft-365/dev-program)", "media": { "markdown": "assets/walkthrough/spfx-intro.md" } }, { "id": "set-up-local-workspace", - "title": "Check and get required dependencies", - "description": "Validate if your local workspace is ready for SharePoint Framework development. \n[Check dependencies](command:spfx-toolkit.checkDependencies)", + "title": "Validate your local workspace", + "description": "Validate if your local workspace is ready for SharePoint Framework development by checking and installing the required dependencies. \n[Check dependencies](command:spfx-toolkit.checkDependencies)", "media": { - "image": "assets/images/validate-dependency.png", - "altText": "Validate dependencies" + "markdown": "assets/walkthrough/validate-local-setup.md" } }, { @@ -103,8 +102,8 @@ }, { "id": "tenant-details", - "title": "Login to Microsoft 365", - "description": "Retrieve tenant helpful information and Manage your SPFx projects. \nLogin to your tenant to get started. \n[Sign in to Microsoft 365](command:spfx-toolkit.login)", + "title": "Sign in to Microsoft 365", + "description": "Retrieve tenant helpful information and Manage your SPFx projects. \nSign in to your tenant to get started. \n[Sign in to Microsoft 365](command:spfx-toolkit.login)", "media": { "markdown": "assets/walkthrough/tenant-details.md" } @@ -167,6 +166,18 @@ "none" ], "description": "Choose your preferred Node.js version manager. Choose between `nvs`, `nvm`, or `none`." + }, + "spfx-toolkit.showServiceIncidentList": { + "title": "Show service health incidents", + "type": "boolean", + "default": "true", + "description": "Show the service health incidents in the account view." + }, + "spfx-toolkit.showTenantWideExtensions": { + "title": "Show tenant-wide extensions", + "type": "boolean", + "default": "true", + "description": "Show the tenant-wide extensions in the account view." } } }, @@ -212,6 +223,13 @@ "fontPath": "./assets/fonts/fabric-icons.woff", "fontCharacter": "\\E95E" } + }, + "entra-id": { + "description": "Entra ID icon", + "default": { + "fontPath": "./assets/fonts/fabric-icons.woff", + "fontCharacter": "\\ED68" + } } }, "viewsContainers": { @@ -331,16 +349,21 @@ }, { "command": "spfx-toolkit.login", - "title": "Sign in to M365", + "title": "Sign in to Microsoft 365", "category": "SharePoint Framework Toolkit", "icon": "$(sign-in)" }, { "command": "spfx-toolkit.logout", - "title": "Sign out from M365", + "title": "Sign out from Microsoft 365", "category": "SharePoint Framework Toolkit", "icon": "$(sign-out)" }, + { + "command": "spfx-toolkit.registerEntraAppRegistration", + "title": "Create a new app registration", + "category": "SharePoint Framework Toolkit" + }, { "command": "spfx-toolkit.refreshAppCatalogTreeView", "title": "Refresh App Catalog list", diff --git a/scripts/cli-for-microsoft365-cleanup.ps1 b/scripts/cli-for-microsoft365-cleanup.ps1 deleted file mode 100644 index e3fa022..0000000 --- a/scripts/cli-for-microsoft365-cleanup.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -param ([string[]]$workspacePath) - -$cliPackagePath = "$workspacePath\vscode-viva\node_modules\@pnp\cli-microsoft365" - -if (Test-Path -Path $cliPackagePath -PathType Container) { - Write-Host "Cleaning up cli-microsoft365 package..." - - Remove-Item -Path "$cliPackagePath\docs" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\chili" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\aad" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\adaptivecard" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\app" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\booking" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\context" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\file" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\flow" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\graph" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\onedrive" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\onenote" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\outlook" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\pa" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\planner" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\pp" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\purview" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\applicationcustomizer" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\apppage" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\cdn" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\commandset" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\contenttype" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\contenttypehub" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\customaction" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\eventreceiver" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\externaluser" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\feature" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\field" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\folder" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\file" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\group" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\hidedefaultthemes" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\homesite" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\hubsite" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\knowledgehub" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\list" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\listitem" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\mail" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\navigation" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\orgassetslibrary" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\orgnewssite" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\page" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\propertybag" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\report" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\search" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\sitedesign" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\sitescript" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\storageentity" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\term" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\theme" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\user" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\userprofile" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\spo\commands\web" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\search" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\skype" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\teams" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\tenant" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\todo" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\viva" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\dist\m365\yammer" -Recurse -Force -ErrorAction SilentlyContinue - - Remove-Item -Path "$cliPackagePath\node_modules\@microsoft\microsoft-graph-types" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\adm-zip" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\inquirer" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\jmespath" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\json-to-ast" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\minimist" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\mocha" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\node" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\node-forge" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\semver" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\sinon" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\update-notifier" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@types\uuid" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@typescript-eslint\eslint-plugin" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\@typescript-eslint\parser" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\c8" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\eslint" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\eslint-plugin-cli-microsoft365" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\eslint-plugin-mocha" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\mocha" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\rimraf" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\sinon" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$cliPackagePath\node_modules\source-map-support" -Recurse -Force -ErrorAction SilentlyContinue -} - diff --git a/src/constants/Commands.ts b/src/constants/Commands.ts index 4d3bc78..61bb1e8 100644 --- a/src/constants/Commands.ts +++ b/src/constants/Commands.ts @@ -5,6 +5,7 @@ export const Commands = { // Authentication login: `${EXTENSION_NAME}.login`, logout: `${EXTENSION_NAME}.logout`, + registerEntraAppRegistration: `${EXTENSION_NAME}.registerEntraAppRegistration`, // Dependencies checkDependencies: `${EXTENSION_NAME}.checkDependencies`, diff --git a/src/constants/WebViewTypes.ts b/src/constants/WebViewTypes.ts index c64d83c..6c05bad 100644 --- a/src/constants/WebViewTypes.ts +++ b/src/constants/WebViewTypes.ts @@ -2,7 +2,8 @@ export enum WebViewType { samplesGallery = 'samplesGallery', workflowForm = 'workflowForm', - scaffoldForm = 'scaffoldForm' + scaffoldForm = 'scaffoldForm', + registerEntraAppRegistration = 'registerEntraAppRegistration' } export const WebViewTypes = [ @@ -20,5 +21,10 @@ export const WebViewTypes = [ Title: 'Scaffold Form', homePageUrl: '/scaffold-form', value: WebViewType.scaffoldForm + }, + { + Title: 'Register Entra App', + homePageUrl: '/register-entra-app-reg', + value: WebViewType.registerEntraAppRegistration } ]; \ No newline at end of file diff --git a/src/constants/WebviewCommand.ts b/src/constants/WebviewCommand.ts index 78d8cc1..af04b04 100644 --- a/src/constants/WebviewCommand.ts +++ b/src/constants/WebviewCommand.ts @@ -16,5 +16,6 @@ export const WebviewCommand = { validateSolutionName: 'validate-solution-name', validateComponentName: 'validate-component-name', addSpfxComponent: 'add-spfx-component', + createAppReg: 'create-app-reg', } }; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index d91d49c..bbcfc94 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,16 +1,17 @@ import { PnPWebview } from './webview/PnPWebview'; import { CommandPanel } from './panels/CommandPanel'; import * as vscode from 'vscode'; -import { workspace, window, ThemeIcon, commands } from 'vscode'; -import { PROJECT_FILE, Scaffolder } from './services/Scaffolder'; -import { Extension } from './services/Extension'; -import { Dependencies } from './services/Dependencies'; +import { workspace, commands } from 'vscode'; +import { PROJECT_FILE, Scaffolder } from './services/actions/Scaffolder'; +import { Extension } from './services/dataType/Extension'; +import { Dependencies } from './services/actions/Dependencies'; import { unlinkSync, readFileSync } from 'fs'; -import { TerminalCommandExecuter } from './services/TerminalCommandExecuter'; +import { TerminalCommandExecuter } from './services/executeWrappers/TerminalCommandExecuter'; import { AuthProvider } from './providers/AuthProvider'; -import { CliActions } from './services/CliActions'; +import { CliActions } from './services/actions/CliActions'; import { PromptHandlers } from './chat/PromptHandlers'; import { CHAT_PARTICIPANT_NAME, ProjectFileContent } from './constants'; +import { EntraAppRegistration } from './services/actions/EntraAppRegistration'; export async function activate(context: vscode.ExtensionContext) { @@ -27,6 +28,7 @@ export async function activate(context: vscode.ExtensionContext) { Dependencies.registerCommands(); Scaffolder.registerCommands(); CliActions.registerCommands(); + EntraAppRegistration.registerCommands(); CommandPanel.register(); @@ -39,13 +41,11 @@ export async function activate(context: vscode.ExtensionContext) { if (fileContents) { unlinkSync(files[0].fsPath); - const terminal = window.createTerminal({ - name: 'Installing dependencies', - iconPath: new ThemeIcon('cloud-download') - }); + const terminalTitle = 'Installing dependencies'; + const terminalIcon = 'cloud-download'; if (fileContents.indexOf(ProjectFileContent.init) > -1 || fileContents.indexOf(ProjectFileContent.initScenario) > -1) { - terminal.sendText('npm i'); + await TerminalCommandExecuter.runCommand('npm i', [], terminalTitle, terminalIcon); } if (fileContents.indexOf(ProjectFileContent.initScenario) > -1) { @@ -53,18 +53,16 @@ export async function activate(context: vscode.ExtensionContext) { } if (fileContents.indexOf(ProjectFileContent.installReusablePropertyPaneControls) > -1) { - terminal.sendText('npm install @pnp/spfx-property-controls --save --save-exact'); + await TerminalCommandExecuter.runCommand('npm install @pnp/spfx-property-controls --save --save-exact', [], terminalTitle, terminalIcon); } if (fileContents.indexOf(ProjectFileContent.installReusableReactControls) > -1) { - terminal.sendText('npm install @pnp/spfx-controls-react --save --save-exact'); + await TerminalCommandExecuter.runCommand('npm install @pnp/spfx-controls-react --save --save-exact', [], terminalTitle, terminalIcon); } if (fileContents.indexOf(ProjectFileContent.installPnPJs) > -1) { - terminal.sendText('npm install @pnp/sp @pnp/graph --save'); + await TerminalCommandExecuter.runCommand('npm install @pnp/sp @pnp/graph --save', [], terminalTitle, terminalIcon); } - - terminal.show(true); } } }); diff --git a/src/models/GenerateWorkflowCommandInput.ts b/src/models/GenerateWorkflowCommandInput.ts index efe5cb0..2f41058 100644 --- a/src/models/GenerateWorkflowCommandInput.ts +++ b/src/models/GenerateWorkflowCommandInput.ts @@ -1,5 +1,6 @@ import { WorkflowType } from '../constants'; + export interface GenerateWorkflowCommandInput { workflowType: WorkflowType name: string; diff --git a/src/models/SpfxAddComponentCommandInput.ts b/src/models/SpfxAddComponentCommandInput.ts index a133b87..84ce734 100644 --- a/src/models/SpfxAddComponentCommandInput.ts +++ b/src/models/SpfxAddComponentCommandInput.ts @@ -1,6 +1,7 @@ import { ComponentType } from '../constants/ComponentTypes'; import { ExtensionType } from '../constants/ExtensionTypes'; + export interface SpfxAddComponentCommandInput { componentType: ComponentType; componentName: string; diff --git a/src/models/SpfxScaffoldCommandInput.ts b/src/models/SpfxScaffoldCommandInput.ts index 3e405db..3e15091 100644 --- a/src/models/SpfxScaffoldCommandInput.ts +++ b/src/models/SpfxScaffoldCommandInput.ts @@ -1,5 +1,6 @@ import { SpfxAddComponentCommandInput } from './SpfxAddComponentCommandInput'; + export interface SpfxScaffoldCommandInput extends SpfxAddComponentCommandInput { folderPath: string; solutionName: string; diff --git a/src/panels/CommandPanel.ts b/src/panels/CommandPanel.ts index 483d762..d660918 100644 --- a/src/panels/CommandPanel.ts +++ b/src/panels/CommandPanel.ts @@ -3,13 +3,15 @@ import { commands, workspace, window, Uri } from 'vscode'; import { Commands, ContextKeys } from '../constants'; import { ActionTreeItem, ActionTreeDataProvider } from '../providers/ActionTreeDataProvider'; import { AuthProvider, M365AuthenticationSession } from '../providers/AuthProvider'; -import { CliActions } from '../services/CliActions'; -import { DebuggerCheck } from '../services/DebuggerCheck'; -import { EnvironmentInformation } from '../services/EnvironmentInformation'; -import { TeamsToolkitIntegration } from '../services/TeamsToolkitIntegration'; -import { AdaptiveCardCheck } from '../services/AdaptiveCardCheck'; +import { CliActions } from '../services/actions/CliActions'; +import { DebuggerCheck } from '../services/check/DebuggerCheck'; +import { EnvironmentInformation } from '../services/dataType/EnvironmentInformation'; +import { TeamsToolkitIntegration } from '../services/dataType/TeamsToolkitIntegration'; +import { AdaptiveCardCheck } from '../services/check/AdaptiveCardCheck'; import { Subscription } from '../models'; -import { Extension } from '../services/Extension'; +import { Extension } from '../services/dataType/Extension'; +import { getExtensionSettings } from '../utils'; +import { EntraApplicationCheck } from '../services/check/EntraApplicationCheck'; export class CommandPanel { @@ -97,9 +99,12 @@ export class CommandPanel { const accountCommands: ActionTreeItem[] = []; if (session) { + EntraApplicationCheck.validateEntraAppRegistrationComponent(session); + 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')); const appCatalogUrls = await CliActions.appCatalogUrlsGet(); if (appCatalogUrls?.some) { @@ -115,15 +120,18 @@ export class CommandPanel { 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 healthInfoList = await CliActions.getTenantHealthInfo(); - if (healthInfoList) - { - 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')); - } - if (healthInfoItems.length > 0) { - accountCommands[0].children.push(new ActionTreeItem('Service health incidents', '', { name: 'm365-health', custom: true }, undefined, undefined, undefined, undefined, healthInfoItems)); + const showServiceIncidentList = getExtensionSettings('showServiceIncidentList', true); + if (showServiceIncidentList === true) { + const healthInfoList = await CliActions.getTenantHealthInfo(); + 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')); + } + if (healthInfoItems.length > 0) { + accountCommands[0].children.push(new ActionTreeItem('Service health incidents', '', { name: 'm365-health', custom: true }, undefined, undefined, undefined, undefined, healthInfoItems)); + } } } } @@ -134,7 +142,7 @@ export class CommandPanel { EnvironmentInformation.reset(); commands.executeCommand('setContext', ContextKeys.isLoggedIn, false); commands.executeCommand('setContext', ContextKeys.hasAppCatalog, false); - accountCommands.push(new ActionTreeItem('Sign in to M365', '', { name: 'M365', custom: true }, undefined, Commands.login)); + accountCommands.push(new ActionTreeItem('Sign in to Microsoft 365', '', { name: 'sign-in', custom: false }, undefined, Commands.login)); } window.createTreeView('pnp-view-account', { treeDataProvider: new ActionTreeDataProvider(accountCommands), showCollapseAll: true }); @@ -155,21 +163,29 @@ export class CommandPanel { const origin = new URL(tenantAppCatalogUrl).origin; commands.executeCommand('setContext', ContextKeys.hasAppCatalog, 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')); - }); - } - 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'), - new ActionTreeItem('Tenant-wide Extensions', '', { name: 'spo-app-list', custom: true }, undefined, undefined, undefined, 'sp-app-catalog-tenant-wide-extensions', tenantWideExtensionsList) + new ActionTreeItem(tenantAppCatalogUrl.replace(origin, '...'), '', { name: 'globe', custom: false }, undefined, 'vscode.open', Uri.parse(tenantAppCatalogUrl), 'sp-app-catalog-url') ]), ); + const showTenantWideExtensions = 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)); + } + + environmentCommands.push(new ActionTreeItem('Tenant-wide Extensions', '', { name: 'spo-app-list', custom: true }, undefined, undefined, undefined, 'sp-app-catalog-tenant-wide-extensions', tenantWideExtensionsList)); + } + 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')); diff --git a/src/providers/AuthProvider.ts b/src/providers/AuthProvider.ts index ad66c80..5d148ce 100644 --- a/src/providers/AuthProvider.ts +++ b/src/providers/AuthProvider.ts @@ -1,25 +1,26 @@ -import { EnvironmentInformation } from './../services/EnvironmentInformation'; +import { EnvironmentInformation } from '../services/dataType/EnvironmentInformation'; import { env, authentication, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, AuthenticationSessionAccountInformation, commands, Disposable, Event, EventEmitter, ProgressLocation, window, Progress } from 'vscode'; import { Commands } from '../constants'; -import { Logger } from '../services/Logger'; -import { Notifications } from '../services/Notifications'; -import { Extension } from './../services/Extension'; +import { Logger } from '../services/dataType/Logger'; +import { Notifications } from '../services/dataType/Notifications'; +import { Extension } from '../services/dataType/Extension'; import { executeCommand } from '@pnp/cli-microsoft365'; import { exec } from 'child_process'; -import { Folders } from '../services/Folders'; -import { TerminalCommandExecuter } from '../services/TerminalCommandExecuter'; +import { Folders } from '../services/check/Folders'; +import { TerminalCommandExecuter } from '../services/executeWrappers/TerminalCommandExecuter'; +import { isValidGUID } from '../utils/validateGuid'; +import { CliExecuter } from '../services/executeWrappers/CliCommandExecuter'; +import { EntraAppRegistration } from '../services/actions/EntraAppRegistration'; export class M365AuthenticationSession implements AuthenticationSession { public readonly id = AuthProvider.id; + public readonly scopes = []; // Scopes are not needed for the M365 CLI + public readonly accessToken: string = ''; // Scopes are not needed for the M365 CLI + public tenantId: string = ''; + public clientId: string = ''; - // Scopes are not needed for the M365 CLI - public readonly scopes = []; - - // Required for the session, but not for M365 CLI - public readonly accessToken: string = ''; - - constructor(public readonly account: AuthenticationSessionAccountInformation) { } + constructor(public readonly account: AuthenticationSessionAccountInformation) {} } export class AuthProvider implements AuthenticationProvider, Disposable { @@ -41,13 +42,13 @@ export class AuthProvider implements AuthenticationProvider, Disposable { subscriptions.push( authentication.registerAuthenticationProvider( AuthProvider.id, - 'M365 Authentication', + 'CLI for Microsoft 365 Authentication', AuthProvider.instance ) ); subscriptions.push( - commands.registerCommand(Commands.login, AuthProvider.login) + commands.registerCommand(Commands.login, AuthProvider.signIn) ); subscriptions.push( commands.registerCommand(Commands.logout, AuthProvider.logout) @@ -70,6 +71,75 @@ export class AuthProvider implements AuthenticationProvider, Disposable { AuthProvider.login(false); } + public static async signIn(createIfNone: boolean = true) { + let shouldGenerateNewEntraAppReg = false; + if (!EnvironmentInformation.clientId) { + const shouldGenerateNewEntraAppRegistration = await window.showQuickPick(['Sign in using existing App Registration', 'Create a new App Registration'], { + title: 'SPFx Toolkit needs an Entra App Registration in order to grant the required permissions when signing in to your tenant. Do you want to provide client ID and tenant ID of an existing Entra App Registration or create a new one?', + ignoreFocusOut: true, + canPickMany: false + }); + + shouldGenerateNewEntraAppReg = shouldGenerateNewEntraAppRegistration === 'Create a new App Registration'; + } + + if (shouldGenerateNewEntraAppReg) { + EntraAppRegistration.showRegisterEntraAppRegistrationPage(); + return; + } + + const clientId = await window.showInputBox({ + title: 'Specify the application (client) ID', + value: EnvironmentInformation.clientId ?? '', + ignoreFocusOut: true, + prompt: 'Please provide the \'Application (client) ID\' of the Entra app registration. If you don\'t have the app registration yet, create one using the \'Create a new Entra app registration\' option.', + validateInput: async (value) => { + if (!value) { + return 'Client ID is required'; + } + + if (!isValidGUID(value)) { + return 'Client ID is not a valid GUID'; + } + + return undefined; + } + }); + + if (!clientId) { + Logger.error('Client ID is required'); + throw new Error('Client ID is required'); + } + + const tenantId = await window.showInputBox({ + title: 'Specify the tenant ID', + value: EnvironmentInformation.tenantId ?? '', + ignoreFocusOut: true, + prompt: 'Please provide GUID of your tenant which may be found as \'Directory (tenant) ID\' Entra app registration overview', + validateInput: async (value) => { + if (!value) { + return 'Tenant ID is required'; + } + + if (!isValidGUID(value)) { + return 'Tenant ID is not a valid GUID'; + } + + return undefined; + } + }); + + if (!tenantId) { + Logger.error('Tenant ID is required'); + throw new Error('Tenant ID is required'); + } + + EnvironmentInformation.clientId = clientId; + EnvironmentInformation.tenantId = tenantId; + + await authentication.getSession(AuthProvider.id, [], { createIfNone }); + } + /** * Logs in the user. * @param createIfNone - A boolean indicating whether to create a new session if none exists. @@ -109,13 +179,15 @@ export class AuthProvider implements AuthenticationProvider, Disposable { * @returns A promise that resolves to an AuthenticationSession. */ public async createSession(_scopes: string[]): Promise { + const clientId = EnvironmentInformation.clientId; + const tenantId = EnvironmentInformation.tenantId; return new Promise((resolve) => { window.withProgress({ location: ProgressLocation.Notification, - title: `Logging in to M365. Check [output window](command:${Commands.showOutputChannel}) for more details`, + title: `Logging in to Microsoft 365. Check [output window](command:${Commands.showOutputChannel}) for more details`, cancellable: true }, async (progress: Progress<{ message?: string; increment?: number }>) => { - await executeCommand('login', { output: 'text' }, { + await executeCommand('login', { output: 'json', appId: clientId, tenant: tenantId }, { stdout: (message: string) => { if (message.includes('https://microsoft.com/devicelogin')) { commands.executeCommand('vscode.open', 'https://microsoft.com/devicelogin'); @@ -135,12 +207,12 @@ export class AuthProvider implements AuthenticationProvider, Disposable { return ''; }, stderr: (message: string) => { - Logger.error(`M365 CLI - login: ${message}`); + Logger.error(`login: ${message}`); return message; } }); - Notifications.info('M365 CLI - Logged in to M365'); + Notifications.info('Logged in to Microsoft 365'); const account = await this.getAccount(); // Bring the editor to the front @@ -160,16 +232,16 @@ export class AuthProvider implements AuthenticationProvider, Disposable { * @returns A Promise that resolves when the session is successfully removed. */ public async removeSession(_sessionId: string): Promise { - const output = await executeCommand('logout', { output: 'text' }); + const output = await CliExecuter.execute('logout', 'json'); if (output.stderr) { - Logger.error(`M365 CLI - logout: ${output.stderr}`); + Logger.error(`logout: ${output.stderr}`); return; } - EnvironmentInformation.account = undefined; + EnvironmentInformation.reset(); - Logger.info('M365 CLI - logged out'); + Logger.info('logged out'); AuthProvider.login(false); this.onDidChangeEventEmit.fire({ added: [], removed: [], changed: [] }); @@ -192,27 +264,38 @@ export class AuthProvider implements AuthenticationProvider, Disposable { const status = await executeCommand('status', { output: 'json' }); if (status.stdout) { - Logger.info(`M365 CLI - status: ${status.stdout}`); + Logger.info(`status: ${status.stdout}`); const sessions = JSON.parse(status.stdout.toString()); if (sessions && sessions.connectedAs) { EnvironmentInformation.account = sessions.connectedAs; - return new M365AuthenticationSession({ - id: sessions.connectedAs, + const account = new M365AuthenticationSession({ + id: AuthProvider.id, label: sessions.connectedAs }); + + account.tenantId = sessions.appTenant ?? ''; + EnvironmentInformation.tenantId = sessions.appTenant; + account.clientId = sessions.appId ?? ''; + EnvironmentInformation.clientId = sessions.appId; + + return account; } } if (status.stderr) { - Logger.error(`M365 CLI - status: ${status.stderr}`); + Logger.error(`status: ${status.stderr}`); } } else { - return new M365AuthenticationSession({ - id: EnvironmentInformation.account, + const account = new M365AuthenticationSession({ + id: AuthProvider.id, label: EnvironmentInformation.account }); + account.tenantId = EnvironmentInformation.tenantId ?? ''; + account.clientId = EnvironmentInformation.clientId ?? ''; + + return account; } return undefined; diff --git a/src/services/CertificateActions.ts b/src/services/actions/CertificateActions.ts similarity index 97% rename from src/services/CertificateActions.ts rename to src/services/actions/CertificateActions.ts index 6f347eb..bd1eae7 100644 --- a/src/services/CertificateActions.ts +++ b/src/services/actions/CertificateActions.ts @@ -1,4 +1,4 @@ -import { Logger } from './Logger'; +import { Logger } from '../dataType/Logger'; import { asn1, pkcs12, pki, util } from 'node-forge'; import path = require('path'); import fs = require('fs'); diff --git a/src/services/CliActions.ts b/src/services/actions/CliActions.ts similarity index 83% rename from src/services/CliActions.ts rename to src/services/actions/CliActions.ts index 2258b8c..e5e0059 100644 --- a/src/services/CliActions.ts +++ b/src/services/actions/CliActions.ts @@ -1,19 +1,18 @@ -import { ServeConfig } from './../models/ServeConfig'; import { readFileSync, writeFileSync } from 'fs'; -import { Folders } from './Folders'; +import { Folders } from '../check/Folders'; import { commands, Progress, ProgressLocation, Uri, window, workspace } from 'vscode'; -import { Commands, WebViewType, WebviewCommand, WorkflowType } from '../constants'; -import { GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, Subscription } from '../models'; -import { Extension } from './Extension'; -import { CliExecuter } from './CliCommandExecuter'; -import { Notifications } from './Notifications'; +import { Commands, WebViewType, WebviewCommand, WorkflowType } from '../../constants'; +import { GenerateWorkflowCommandInput, SiteAppCatalog, SolutionAddResult, Subscription } from '../../models'; +import { Extension } from '../dataType/Extension'; +import { CliExecuter } from '../executeWrappers/CliCommandExecuter'; +import { Notifications } from '../dataType/Notifications'; import { basename, join } from 'path'; -import { EnvironmentInformation } from './EnvironmentInformation'; -import { AuthProvider } from '../providers/AuthProvider'; +import { EnvironmentInformation } from '../dataType/EnvironmentInformation'; +import { AuthProvider } from '../../providers/AuthProvider'; import { CommandOutput } from '@pnp/cli-microsoft365'; -import { TeamsToolkitIntegration } from './TeamsToolkitIntegration'; -import { PnPWebview } from '../webview/PnPWebview'; -import { parseYoRc } from '../utils/parseYoRc'; +import { TeamsToolkitIntegration } from '../dataType/TeamsToolkitIntegration'; +import { PnPWebview } from '../../webview/PnPWebview'; +import { parseYoRc } from '../../utils/parseYoRc'; import { CertificateActions } from './CertificateActions'; import path = require('path'); @@ -41,9 +40,6 @@ export class CliActions { subscriptions.push( commands.registerCommand(Commands.pipeline, CliActions.showGenerateWorkflowForm) ); - subscriptions.push( - commands.registerCommand(Commands.serveProject, CliActions.serveProject) - ); } /** @@ -51,21 +47,25 @@ export class CliActions { * @returns A promise that resolves to an array of app catalog URLs, or undefined if no app catalogs are found. */ public static async appCatalogUrlsGet(): Promise { - const appCatalogUrls: string[] = []; - const tenantAppCatalog = (await CliExecuter.execute('spo tenant appcatalogurl get', 'json')).stdout || undefined; - const siteAppCatalogs = (await CliExecuter.execute('spo site appcatalog list', 'json')).stdout || undefined; + try { + const appCatalogUrls: string[] = []; + const tenantAppCatalog = (await CliExecuter.execute('spo tenant appcatalogurl get', 'json')).stdout || undefined; + const siteAppCatalogs = (await CliExecuter.execute('spo site appcatalog list', 'json')).stdout || undefined; - if (tenantAppCatalog) { - appCatalogUrls.push(JSON.parse(tenantAppCatalog)); - } + if (tenantAppCatalog) { + appCatalogUrls.push(JSON.parse(tenantAppCatalog)); + } - if (siteAppCatalogs) { - const siteAppCatalogsJson: SiteAppCatalog[] = JSON.parse(siteAppCatalogs); - siteAppCatalogsJson.forEach((siteAppCatalog) => appCatalogUrls.push(`${siteAppCatalog.AbsoluteUrl}/AppCatalog`)); - } + if (siteAppCatalogs) { + const siteAppCatalogsJson: SiteAppCatalog[] = JSON.parse(siteAppCatalogs); + siteAppCatalogsJson.forEach((siteAppCatalog) => appCatalogUrls.push(`${siteAppCatalog.AbsoluteUrl}/AppCatalog`)); + } - EnvironmentInformation.appCatalogUrls = appCatalogUrls ? appCatalogUrls : undefined; - return EnvironmentInformation.appCatalogUrls; + EnvironmentInformation.appCatalogUrls = appCatalogUrls ? appCatalogUrls : undefined; + return EnvironmentInformation.appCatalogUrls; + } catch { + return undefined; + } } /** @@ -74,26 +74,30 @@ export class CliActions { * @returns A promise that resolves to an array of objects containing the URL and title of each tenant-wide extension, * or undefined if no extensions are found. */ - public static async getTenantWideExtensions(tenantAppCatalogUrl: string): Promise<{Url: string, Title: string}[] | undefined> { + public static async getTenantWideExtensions(tenantAppCatalogUrl: string): Promise<{ Url: string, Title: string }[] | undefined> { const origin = new URL(tenantAppCatalogUrl).origin; const commandOptions: any = { listUrl: `${tenantAppCatalogUrl.replace(origin, '')}/Lists/TenantWideExtensions`, webUrl: tenantAppCatalogUrl }; - const tenantWideExtensions = (await CliExecuter.execute('spo listitem list', 'json', commandOptions)).stdout || undefined; + try { + const tenantWideExtensions = (await CliExecuter.execute('spo listitem list', 'json', commandOptions)).stdout || undefined; - if (!tenantWideExtensions) { + if (!tenantWideExtensions) { + return undefined; + } + + const tenantWideExtensionsJson: any[] = JSON.parse(tenantWideExtensions); + const tenantWideExtensionList = tenantWideExtensionsJson.map((extension) => { + return { + Url: `${tenantAppCatalogUrl}/Lists/TenantWideExtensions/DispForm.aspx?ID=${extension.Id}`, + Title: extension.Title + }; + }); + return tenantWideExtensionList; + } catch { return undefined; } - - const tenantWideExtensionsJson: any[] = JSON.parse(tenantWideExtensions); - const tenantWideExtensionList = tenantWideExtensionsJson.map((extension) => { - return { - Url: `${tenantAppCatalogUrl}/Lists/TenantWideExtensions/DispForm.aspx?ID=${extension.Id}`, - Title: extension.Title - }; - }); - return tenantWideExtensionList; } /** @@ -101,21 +105,25 @@ export class CliActions { * @returns A promise that resolves to an array of objects containing the title and URL of the health information. * Returns undefined if there is no health information available. */ - public static async getTenantHealthInfo(): Promise<{Title: string, Url: string}[] | undefined> { - const healthInfo = (await CliExecuter.execute('tenant serviceannouncement health list', 'json')).stdout || undefined; + public static async getTenantHealthInfo(): Promise<{ Title: string, Url: string }[] | undefined> { + try { + const healthInfo = (await CliExecuter.execute('tenant serviceannouncement health list', 'json')).stdout || undefined; + if (!healthInfo) { + return undefined; + } - if (!healthInfo) { + const healthInfoJson: any[] = JSON.parse(healthInfo); + const healthInfoList = healthInfoJson.filter(service => service.status !== 'serviceOperational').map((service) => { + return { + Url: `https://admin.microsoft.com/#/servicehealth/:/currentIssues/${encodeURIComponent(service.service)}/`, + Title: service.service + }; + }); + return healthInfoList; + } + catch { return undefined; } - - const healthInfoJson: any[] = JSON.parse(healthInfo); - const healthInfoList = healthInfoJson.filter(service => service.status !== 'serviceOperational').map((service) => { - return { - Url: `https://admin.microsoft.com/#/servicehealth/:/currentIssues/${encodeURIComponent(service.service)}/`, - Title: service.service - }; - }); - return healthInfoList; } /** @@ -565,37 +573,4 @@ export class CliActions { } }); } - - /** - * Serves the project by executing the specified configuration using Gulp. - * Prompts the user to select a configuration from the serve.json file. - */ - public static async serveProject() { - const wsFolder = Folders.getWorkspaceFolder(); - if (!wsFolder) { - return; - } - - const serveFiles = await workspace.findFiles('config/serve.json', '**/node_modules/**'); - const serveFile = serveFiles && serveFiles.length > 0 ? serveFiles[0] : null; - - if (!serveFile) { - return; - } - - const serveFileContents = readFileSync(serveFile.fsPath, 'utf8'); - const serveFileData: ServeConfig = JSON.parse(serveFileContents); - const configNames = Object.keys(serveFileData.serveConfigurations); - - const answer = await window.showQuickPick(configNames, { - title: 'Select the configuration to serve', - ignoreFocusOut: true - }); - - if (!answer) { - return; - } - - commands.executeCommand(Commands.executeTerminalCommand, `gulp serve --config=${answer}`); - } } \ No newline at end of file diff --git a/src/services/Dependencies.ts b/src/services/actions/Dependencies.ts similarity index 89% rename from src/services/Dependencies.ts rename to src/services/actions/Dependencies.ts index 8d36ee1..54f989f 100644 --- a/src/services/Dependencies.ts +++ b/src/services/actions/Dependencies.ts @@ -1,11 +1,11 @@ -import { Commands } from './../constants/Commands'; -import { Notifications } from './Notifications'; +import { Commands } from '../../constants/Commands'; +import { Notifications } from '../dataType/Notifications'; import { execSync } from 'child_process'; -import { commands, ProgressLocation, ThemeIcon, window } from 'vscode'; -import { Logger } from './Logger'; -import { NpmLs, Subscription } from '../models'; -import { TerminalCommandExecuter } from './TerminalCommandExecuter'; -import { Extension } from './Extension'; +import { commands, ProgressLocation, window } from 'vscode'; +import { Logger } from '../dataType/Logger'; +import { NpmLs, Subscription } from '../../models'; +import { TerminalCommandExecuter } from '../executeWrappers/TerminalCommandExecuter'; +import { Extension } from '../dataType/Extension'; const SUPPORTED_VERSIONS = ['18.17.1']; @@ -94,16 +94,8 @@ export class Dependencies { /** * Installs the dependencies by running the npm install command in a terminal. */ - public static install() { - const terminal = window.createTerminal({ - name: 'Installing dependencies', - iconPath: new ThemeIcon('cloud-download') - }); - - if (terminal) { - terminal.sendText(`npm i -g ${DEPENDENCIES.join(' ')}`); - terminal.show(true); - } + public static async install() { + await TerminalCommandExecuter.runCommand(`npm i -g ${DEPENDENCIES.join(' ')}`, [], 'Installing dependencies', 'cloud-download'); } /** diff --git a/src/services/actions/EntraAppRegistration.ts b/src/services/actions/EntraAppRegistration.ts new file mode 100644 index 0000000..5420d8a --- /dev/null +++ b/src/services/actions/EntraAppRegistration.ts @@ -0,0 +1,69 @@ +import { window, commands, ProgressLocation, Progress } from 'vscode'; +import { Extension } from '../dataType/Extension'; +import { Subscription } from '../../models'; +import { Commands, WebViewType } from '../../constants'; +import { PnPWebview } from '../../webview/PnPWebview'; +import { executeCommand } from '@pnp/cli-microsoft365'; +import { Logger } from '../dataType/Logger'; +import { Notifications } from '../dataType/Notifications'; +import { EnvironmentInformation } from '../dataType/EnvironmentInformation'; +import { AuthProvider } from '../../providers/AuthProvider'; + + +export class EntraAppRegistration { + + public static registerCommands() { + const subscriptions: Subscription[] = Extension.getInstance().subscriptions; + + subscriptions.push( + commands.registerCommand(Commands.registerEntraAppRegistration, EntraAppRegistration.showRegisterEntraAppRegistrationPage) + ); + } + + /** + * Opens the Entra App Registration page in a webview. + */ + public static async showRegisterEntraAppRegistrationPage() { + PnPWebview.open(WebViewType.registerEntraAppRegistration); + } + + /** + * Creates an Entra App Registration. + * This method creates an Entra App Registration by executing the 'spfxToolkit' command and retrieving the necessary details. + * It opens a web browser for the user to sign in to their tenant and then retrieves the tenant ID and client ID. + * @returns {Promise} A promise that resolves when the Entra App Registration is created successfully. + */ + public static async createEntraAppRegistration() { + new Promise((resolve) => { + window.withProgress({ + location: ProgressLocation.Notification, + title: 'Creating Entra App Registration. Please use the web browser that just has been opened to sign-in to your tenant.', + cancellable: true + }, async (progress: Progress<{ message?: string; increment?: number }>) => { + await executeCommand('spfxToolkit', { output: 'json'}, { + stdout: (message: string) => { + try { + const entraAppDetails = JSON.parse(message); + Logger.info(`Created Entra App Registration: ${JSON.stringify(entraAppDetails)}`); + EnvironmentInformation.tenantId = entraAppDetails.tenantId; + EnvironmentInformation.clientId = entraAppDetails.appId; + } catch (error) { + Logger.error(`Creating Entra App Registration Error: ${message}`); + } + return; + }, + stderr: (message: string) => { + if (!message.includes('use the web browser')) { + Logger.error(`Creating Entra App Registration Error: ${message}`); + } + return; + } + }); + + Notifications.info('SPFx Toolkit App Registration created successfully'); + PnPWebview.close(); + AuthProvider.signIn(); + }); + }); + } +} \ No newline at end of file diff --git a/src/services/Scaffolder.ts b/src/services/actions/Scaffolder.ts similarity index 95% rename from src/services/Scaffolder.ts rename to src/services/actions/Scaffolder.ts index d6faa24..8ee79e1 100644 --- a/src/services/Scaffolder.ts +++ b/src/services/actions/Scaffolder.ts @@ -1,22 +1,20 @@ -import { parseWinPath } from './../utils/parseWinPath'; -import { Folders } from './Folders'; -import { Notifications } from './Notifications'; -import { Logger } from './Logger'; +import { parseWinPath } from '../../utils/parseWinPath'; +import { Folders } from '../check/Folders'; +import { Notifications } from '../dataType/Notifications'; +import { Logger } from '../dataType/Logger'; import { commands, ProgressLocation, QuickPickItem, Uri, window } from 'vscode'; -import { Commands, ComponentType, ProjectFileContent, WebviewCommand, WebViewType } from '../constants'; -import { Sample, SpfxAddComponentCommandInput, SpfxScaffoldCommandInput, Subscription } from '../models'; +import { Commands, ComponentType, ProjectFileContent, WebviewCommand, WebViewType } from '../../constants'; +import { Sample, SpfxAddComponentCommandInput, SpfxScaffoldCommandInput, Subscription } from '../../models'; import { join } from 'path'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import * as glob from 'fast-glob'; -import { Extension } from './Extension'; +import { Extension } from '../dataType/Extension'; import download from 'github-directory-downloader/esm'; -import { CliExecuter } from './CliCommandExecuter'; -import { getPlatform } from '../utils'; -import { TerminalCommandExecuter } from './TerminalCommandExecuter'; -import { execSync } from 'child_process'; -import { PnPWebview } from '../webview/PnPWebview'; -import { Executer } from './CommandExecuter'; -import { TeamsToolkitIntegration } from './TeamsToolkitIntegration'; +import { CliExecuter } from '../executeWrappers/CliCommandExecuter'; +import { getPlatform } from '../../utils'; +import { PnPWebview } from '../../webview/PnPWebview'; +import { Executer } from '../executeWrappers/CommandExecuter'; +import { TeamsToolkitIntegration } from '../dataType/TeamsToolkitIntegration'; export const PROJECT_FILE = 'project.pnp'; diff --git a/src/services/AdaptiveCardCheck.ts b/src/services/check/AdaptiveCardCheck.ts similarity index 88% rename from src/services/AdaptiveCardCheck.ts rename to src/services/check/AdaptiveCardCheck.ts index 877d053..831db1a 100644 --- a/src/services/AdaptiveCardCheck.ts +++ b/src/services/check/AdaptiveCardCheck.ts @@ -1,7 +1,7 @@ import { extensions, env, Uri } from 'vscode'; -import { Logger } from './Logger'; -import { Notifications } from './Notifications'; -import { parseYoRc } from '../utils/parseYoRc'; +import { Logger } from '../dataType/Logger'; +import { Notifications } from '../dataType/Notifications'; +import { parseYoRc } from '../../utils/parseYoRc'; export class AdaptiveCardCheck { diff --git a/src/services/DebuggerCheck.ts b/src/services/check/DebuggerCheck.ts similarity index 95% rename from src/services/DebuggerCheck.ts rename to src/services/check/DebuggerCheck.ts index a29ed3e..5baeb0f 100644 --- a/src/services/DebuggerCheck.ts +++ b/src/services/check/DebuggerCheck.ts @@ -1,9 +1,9 @@ -import { ServeConfig } from './../models/ServeConfig'; +import { ServeConfig } from '../../models/ServeConfig'; import { writeFileSync } from 'fs'; import { workspace } from 'vscode'; -import { VSCodeLaunch } from '../models'; -import { Notifications } from './Notifications'; -import { Logger } from './Logger'; +import { VSCodeLaunch } from '../../models'; +import { Notifications } from '../dataType/Notifications'; +import { Logger } from '../dataType/Logger'; export class DebuggerCheck { diff --git a/src/services/check/EntraApplicationCheck.ts b/src/services/check/EntraApplicationCheck.ts new file mode 100644 index 0000000..67222fe --- /dev/null +++ b/src/services/check/EntraApplicationCheck.ts @@ -0,0 +1,32 @@ +import { Logger } from '../dataType/Logger'; +import { Notifications } from '../dataType/Notifications'; +import { AuthProvider, M365AuthenticationSession } from '../../providers/AuthProvider'; +import { env, Uri } from 'vscode'; +import { EntraAppRegistration } from '../actions/EntraAppRegistration'; + + +export class EntraApplicationCheck { + + /** + * Validates the Entra App registration component for a given M365AuthenticationSession. + * @param session - The M365AuthenticationSession to validate. + * @returns A Promise that resolves when the validation is complete. + * @throws An error if there is an issue validating the session. + */ + public static async validateEntraAppRegistrationComponent(session: M365AuthenticationSession) { + try { + if (session.clientId === '31359c7f-bd7e-475c-86db-fdb8c937548e') { + const answer = await Notifications.info('You are still using \'PnP Management Shell\' application as your login method. It is highly recommended to switch to your own single-tenant Entra App registration to grant the needed permissions for SPFx Toolkit.', 'Reauthenticate', 'More information'); + + if (answer === 'More information') { + env.openExternal(Uri.parse('https://github.com/pnp/vscode-viva/wiki/5.3-Login-to-your-tenant-&-retrieve-environment-details')); + } else if (answer === 'Reauthenticate') { + AuthProvider.logout(); + EntraAppRegistration.showRegisterEntraAppRegistrationPage(); + } + } + } catch (e) { + Logger.error(`Error validating session Entra Application: ${e}`); + } + } +} \ No newline at end of file diff --git a/src/services/Folders.ts b/src/services/check/Folders.ts similarity index 100% rename from src/services/Folders.ts rename to src/services/check/Folders.ts diff --git a/src/services/EnvironmentInformation.ts b/src/services/dataType/EnvironmentInformation.ts similarity index 54% rename from src/services/EnvironmentInformation.ts rename to src/services/dataType/EnvironmentInformation.ts index 2839097..fa11905 100644 --- a/src/services/EnvironmentInformation.ts +++ b/src/services/dataType/EnvironmentInformation.ts @@ -1,6 +1,8 @@ export class EnvironmentInformation { private static _appCatalogUrls: string[] | undefined = undefined; private static _account: string | undefined = undefined; + private static _tenantId: string | undefined = undefined; + private static _clientId: string | undefined = undefined; public static get appCatalogUrls(): string[] | undefined { return this._appCatalogUrls; @@ -18,8 +20,26 @@ export class EnvironmentInformation { this._account = value; } + public static get tenantId(): string | undefined { + return this._tenantId; + } + + public static set tenantId(value: string | undefined) { + this._tenantId = value; + } + + public static get clientId(): string | undefined { + return this._clientId; + } + + public static set clientId(value: string | undefined) { + this._clientId = value; + } + public static reset() { this._appCatalogUrls = undefined; this._account = undefined; + this._tenantId = undefined; + this._clientId = undefined; } } \ No newline at end of file diff --git a/src/services/Extension.ts b/src/services/dataType/Extension.ts similarity index 100% rename from src/services/Extension.ts rename to src/services/dataType/Extension.ts diff --git a/src/services/Logger.ts b/src/services/dataType/Logger.ts similarity index 95% rename from src/services/Logger.ts rename to src/services/dataType/Logger.ts index 4467917..e014f41 100644 --- a/src/services/Logger.ts +++ b/src/services/dataType/Logger.ts @@ -1,6 +1,6 @@ import { Extension } from './Extension'; import { commands, OutputChannel, window } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../../constants'; export class Logger { diff --git a/src/services/Notifications.ts b/src/services/dataType/Notifications.ts similarity index 95% rename from src/services/Notifications.ts rename to src/services/dataType/Notifications.ts index 6b1c69e..e9dd33d 100644 --- a/src/services/Notifications.ts +++ b/src/services/dataType/Notifications.ts @@ -1,5 +1,5 @@ import { window } from 'vscode'; -import { Extension } from './Extension'; +import { Extension } from '../dataType/Extension'; import { Logger } from './Logger'; diff --git a/src/services/TeamsToolkitIntegration.ts b/src/services/dataType/TeamsToolkitIntegration.ts similarity index 100% rename from src/services/TeamsToolkitIntegration.ts rename to src/services/dataType/TeamsToolkitIntegration.ts diff --git a/src/services/CliCommandExecuter.ts b/src/services/executeWrappers/CliCommandExecuter.ts similarity index 97% rename from src/services/CliCommandExecuter.ts rename to src/services/executeWrappers/CliCommandExecuter.ts index 5a550b7..b34b013 100644 --- a/src/services/CliCommandExecuter.ts +++ b/src/services/executeWrappers/CliCommandExecuter.ts @@ -1,4 +1,4 @@ -import { Logger } from './Logger'; +import { Logger } from '../dataType/Logger'; import { CommandOutput, executeCommand } from '@pnp/cli-microsoft365'; diff --git a/src/services/CommandExecuter.ts b/src/services/executeWrappers/CommandExecuter.ts similarity index 96% rename from src/services/CommandExecuter.ts rename to src/services/executeWrappers/CommandExecuter.ts index 901e6ec..7746a7a 100644 --- a/src/services/CommandExecuter.ts +++ b/src/services/executeWrappers/CommandExecuter.ts @@ -1,7 +1,7 @@ import { ChildProcess, spawn, SpawnOptions } from 'child_process'; import * as os from 'os'; -import { CommandResult } from '../models'; -import { Logger } from './Logger'; +import { CommandResult } from '../../models'; +import { Logger } from '../dataType/Logger'; export class Executer { diff --git a/src/services/TerminalCommandExecuter.ts b/src/services/executeWrappers/TerminalCommandExecuter.ts similarity index 73% rename from src/services/TerminalCommandExecuter.ts rename to src/services/executeWrappers/TerminalCommandExecuter.ts index 287642b..f68f835 100644 --- a/src/services/TerminalCommandExecuter.ts +++ b/src/services/executeWrappers/TerminalCommandExecuter.ts @@ -1,11 +1,13 @@ import { commands, ThemeIcon, workspace, window, Terminal } from 'vscode'; -import { Commands, EXTENSION_NAME, NodeVersionManagers } from '../constants'; -import { Subscription } from '../models'; -import { Extension } from './Extension'; -import { getPlatform } from '../utils'; -import { TeamsToolkitIntegration } from './TeamsToolkitIntegration'; -import { Folders } from './Folders'; +import { Commands, NodeVersionManagers } from '../../constants'; +import { Subscription } from '../../models'; +import { Extension } from '../dataType/Extension'; +import { getPlatform, getExtensionSettings } from '../../utils'; +import { TeamsToolkitIntegration } from '../dataType/TeamsToolkitIntegration'; +import { Folders } from '../check/Folders'; import { join } from 'path'; +import { ServeConfig } from '../../models/ServeConfig'; +import { readFileSync } from 'fs'; interface ShellSetting { @@ -17,7 +19,12 @@ export class TerminalCommandExecuter { public static register() { const subscriptions: Subscription[] = Extension.getInstance().subscriptions; - TerminalCommandExecuter.registerCommands(subscriptions); + subscriptions.push( + commands.registerCommand(Commands.serveProject, TerminalCommandExecuter.serveProject) + ); + subscriptions.push( + commands.registerCommand(Commands.executeTerminalCommand, TerminalCommandExecuter.runCommand) + ); TerminalCommandExecuter.initShellPath(); } @@ -30,6 +37,61 @@ export class TerminalCommandExecuter { return TerminalCommandExecuter.shellPath; } + /** + * Runs a command in the terminal. + * @param command - The command to run. + * @param args - The arguments for the command. + */ + public static async runCommand(command: string, args: string[], terminalTitle: string = 'Gulp task', terminalIcon: string = 'tasks-list-configure') { + const terminal = await TerminalCommandExecuter.createTerminal(terminalTitle, terminalIcon); + + const wsFolder = await Folders.getWorkspaceFolder(); + if (wsFolder) { + let currentProjectPath = wsFolder.uri.fsPath; + + if (TeamsToolkitIntegration.isTeamsToolkitProject) { + currentProjectPath = join(currentProjectPath, 'src'); + } + + TerminalCommandExecuter.runInTerminal(`cd "${currentProjectPath}"`, terminal); + } + + TerminalCommandExecuter.runInTerminal(command, terminal); + } + + /** + * Serves the project by executing the specified configuration using Gulp. + * Prompts the user to select a configuration from the serve.json file. + */ + public static async serveProject() { + const wsFolder = Folders.getWorkspaceFolder(); + if (!wsFolder) { + return; + } + + const serveFiles = await workspace.findFiles('config/serve.json', '**/node_modules/**'); + const serveFile = serveFiles && serveFiles.length > 0 ? serveFiles[0] : null; + + if (!serveFile) { + return; + } + + const serveFileContents = readFileSync(serveFile.fsPath, 'utf8'); + const serveFileData: ServeConfig = JSON.parse(serveFileContents); + const configNames = Object.keys(serveFileData.serveConfigurations); + + const answer = await window.showQuickPick(configNames, { + title: 'Select the configuration to serve', + ignoreFocusOut: true + }); + + if (!answer) { + return; + } + + commands.executeCommand(Commands.executeTerminalCommand, `gulp serve --config=${answer}`); + } + /** * Initializes the shell path for executing terminal commands. * If the shell path is an object with a `path` property, it sets the `shellPath` to that value. @@ -69,16 +131,6 @@ export class TerminalCommandExecuter { return terminalSettings.get(`integrated.shell.${platform}`); } - /** - * Registers the commands for execution. - * @param subscriptions - The array of subscriptions to add the registered command to. - */ - private static registerCommands(subscriptions: Subscription[]) { - subscriptions.push( - commands.registerCommand(Commands.executeTerminalCommand, TerminalCommandExecuter.runCommand) - ); - } - /** * Creates a new terminal with the specified name and icon. * If a terminal with the same name already exists, it returns that terminal instead. @@ -99,7 +151,7 @@ export class TerminalCommandExecuter { // Check the user's settings to see if they want to use nvm or nvs // Get the user's preferred node version manager -- nvm or nvs or none, if they don't want to use either - const nodeVersionManager: string = TerminalCommandExecuter.getExtensionSettings('nodeVersionManager', 'nvm'); + const nodeVersionManager: string = getExtensionSettings('nodeVersionManager', 'nvm'); // Check if nvm is used const nvmFiles = await workspace.findFiles('.nvmrc', '**/node_modules/**'); @@ -117,17 +169,6 @@ export class TerminalCommandExecuter { return terminal; } - /** - * Retrieves the extension settings value for the specified setting. - * If the setting is not found, the default value is returned. - * @param setting - The name of the setting to retrieve. - * @param defaultValue - The default value to return if the setting is not found. - * @returns The value of the setting, or the default value if the setting is not found. - */ - private static getExtensionSettings(setting: string, defaultValue: T): T { - return workspace.getConfiguration(EXTENSION_NAME).get(setting, defaultValue); - } - /** * Runs a command in the specified terminal. * @param command - The command to run. @@ -139,26 +180,4 @@ export class TerminalCommandExecuter { terminal.sendText(` ${command}`); } } - - /** - * Runs a command in the terminal. - * @param command - The command to run. - * @param args - The arguments for the command. - */ - public static async runCommand(command: string, args: string[]) { - const terminal = await TerminalCommandExecuter.createTerminal('Gulp task', 'tasks-list-configure'); - - const wsFolder = await Folders.getWorkspaceFolder(); - if (wsFolder) { - let currentProjectPath = wsFolder.uri.fsPath; - - if (TeamsToolkitIntegration.isTeamsToolkitProject) { - currentProjectPath = join(currentProjectPath, 'src'); - } - - TerminalCommandExecuter.runInTerminal(`cd "${currentProjectPath}"`, terminal); - } - - TerminalCommandExecuter.runInTerminal(command, terminal); - } } \ No newline at end of file diff --git a/src/utils/getExtensionSettings.ts b/src/utils/getExtensionSettings.ts new file mode 100644 index 0000000..794efc1 --- /dev/null +++ b/src/utils/getExtensionSettings.ts @@ -0,0 +1,14 @@ +import { workspace } from 'vscode'; +import { EXTENSION_NAME } from '../constants'; + + +/** + * Retrieves the extension settings value for the specified setting. + * If the setting is not found, the default value is returned. + * @param setting - The name of the setting to retrieve. + * @param defaultValue - The default value to return if the setting is not found. + * @returns The value of the setting, or the default value if the setting is not found. + */ +export const getExtensionSettings = (setting: any, defaultValue: any) =>{ + return workspace.getConfiguration(EXTENSION_NAME).get(setting, defaultValue); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 0c761c8..ace4d5b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ export * from './parseWinPath'; export * from './getPlatform'; -export * from './parseYoRc'; \ No newline at end of file +export * from './parseYoRc'; +export * from './getExtensionSettings'; +export * from './validateGuid'; \ No newline at end of file diff --git a/src/utils/validateGuid.ts b/src/utils/validateGuid.ts new file mode 100644 index 0000000..a71c45b --- /dev/null +++ b/src/utils/validateGuid.ts @@ -0,0 +1,4 @@ +export function isValidGUID(guid: string): boolean { + const guidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[4][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + return guidRegex.test(guid); +} diff --git a/src/webview/PnPWebview.ts b/src/webview/PnPWebview.ts index c7cc12c..6214a4e 100644 --- a/src/webview/PnPWebview.ts +++ b/src/webview/PnPWebview.ts @@ -1,10 +1,11 @@ import { join } from 'path'; import { commands, Uri, ViewColumn, Webview, WebviewPanel, window, env } from 'vscode'; import { Commands, WebViewType, WebViewTypes, WebviewCommand } from '../constants'; -import { Extension } from '../services/Extension'; -import { Logger } from '../services/Logger'; -import { Scaffolder } from '../services/Scaffolder'; -import { CliActions } from '../services/CliActions'; +import { Extension } from '../services/dataType/Extension'; +import { Logger } from '../services/dataType/Logger'; +import { Scaffolder } from '../services/actions/Scaffolder'; +import { CliActions } from '../services/actions/CliActions'; +import { EntraAppRegistration } from '../services/actions/EntraAppRegistration'; export class PnPWebview { @@ -144,6 +145,9 @@ export class PnPWebview { case WebviewCommand.toVSCode.addSpfxComponent: Scaffolder.addComponentToProject(payload); break; + case WebviewCommand.toVSCode.createAppReg: + EntraAppRegistration.createEntraAppRegistration(); + break; } }); } diff --git a/src/webview/view/components/App.tsx b/src/webview/view/components/App.tsx index daf0da9..7ac037e 100644 --- a/src/webview/view/components/App.tsx +++ b/src/webview/view/components/App.tsx @@ -8,6 +8,7 @@ import { WebviewCommand } from '../../../constants'; import { routeEntries } from '..'; import { ScaffoldWorkflowView } from './forms/workflow/ScaffoldWorkflowView'; import { ScaffoldSpfxProjectView } from './forms/spfxProject/ScaffoldSpfxProjectView'; +import { RegisterEntraAppRegView } from './forms/entraAppReg/RegisterEntraAppRegView'; export interface IAppProps { @@ -44,6 +45,7 @@ export const App: React.FunctionComponent = ({ url, data }: React.Pro } /> } /> } /> + } /> ); }; \ No newline at end of file diff --git a/src/webview/view/components/forms/entraAppReg/RegisterEntraAppRegView.tsx b/src/webview/view/components/forms/entraAppReg/RegisterEntraAppRegView.tsx new file mode 100644 index 0000000..dd77672 --- /dev/null +++ b/src/webview/view/components/forms/entraAppReg/RegisterEntraAppRegView.tsx @@ -0,0 +1,164 @@ +import { VSCodeButton, VSCodeLink, VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react'; +import * as React from 'react'; +import { useState } from 'react'; +import { WebviewCommand } from '../../../../../constants'; +import { Messenger } from '@estruyf/vscode/dist/client'; + + +export interface IRegisterEntraAppRegViewProps { } + +export const RegisterEntraAppRegView: React.FunctionComponent = ({ }: React.PropsWithChildren) => { + const [isManualMethod, setEntraAppRegistrationMethod] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const submit = () => { + setIsSubmitting(true); + + Messenger.send(WebviewCommand.toVSCode.createAppReg); + }; + + return ( +
+
+
+

Register Single Tenant App Registration

+
+
+ +
+
+
+ +
+
+ +
+ setEntraAppRegistrationMethod(!isManualMethod)} appearance={!isManualMethod ? '' : 'secondary'} className={'float-left'}> + Automatically + + setEntraAppRegistrationMethod(!isManualMethod)} appearance={isManualMethod ? '' : 'secondary'} className={'float-left'}> + Manually + +
+
+
+

SPFx Toolkit will use CLI for Microsoft 365 to sign in as Azure CLI app to your tenant to create a new App Registration.

+
+
+ + Create the Entra App Registration + +
+
+
+ + +

Adding SPFx Toolkit App Registration...

+
+
+
+

In this process the following Entra App Registration will be created:

+ + + + + + + + + + + + + + + + + + + + + +
NameSPFx Toolkit
Platform configurationsMobile and desktop applications
Redirect URL + http://localhost,
https://localhost,
https://login.microsoftonline.com/common/oauth2/nativeclient +
Supported account typesSingle Tenant
Scopes + Microsoft Graph:
+ AppCatalog.ReadWrite.All
+ AuditLog.Read.All
+ SecurityEvents.Read.All
+ ServiceHealth.Read.All
+ ServiceMessage.Read.All
+ Sites.Read.All
+ User.Read
+
+ Office 365 Management APIs:
+ ActivityFeed.Read
+ ServiceHealth.Read
+
+ SharePoint:
+ AllSites.FullControl
+ User.ReadWrite.All
+
+
+
+

Please follow the below steps to Create the Entra App Registration manually

+
    +
  • Navigate to the Azure Portal
  • +
  • Select Microsoft Entra ID from the global menu, select App Registrations in the Microsoft Entra ID blade and then select the New registration action button to open the Register an application form.
  • +
  • In the form, enter a name for your new application. It's recommended to name this app SPFx Toolkit but you may give it any preferable name
  • +
  • Leave the Supported account types and Redirect URI values as they are and select the Register button at the foot of the form to create your custom application
  • +
  • Next we need to configure the Authentication for our new app. Go to the Authentication page and select the Add a platform button to open up the Configure platforms menu and under the Mobile and desktop applications heading, select Mobile and desktop applications. This will open another menu called Configure Desktop + Devices displaying a section called Redirect URIs and a list of checkboxes with some pre-defined URIs.
  • +
  • Select the first option in the list, https://login.microsoftonline.com/common/oauth2/nativeclient and select the Configure button at the foot of the menu.
  • +
  • we can skip over the Supported account type section, as this is defaulted to Accounts in this organizational directory only (tenant only - Single tenant) meaning, that only users within the current tenant directory can use this application.
  • +
  • In the Advanced settings section, we need to enable the Allow public client flows toggle, as we are using the Device code flow method to authenticate to our tenant using the CLI for Microsoft 365.
  • +
  • To make sure all these changes are applied, select the Save button before moving on.
  • +
  • Now that we have configured the application to work with the SPFx Toolkit, we next need to grant the required permissions. Select the API permissions in the menu option. + You will see a section called Configured permissions with one permission already granted. This is the default permission which allows the application to sign in the user account used when authenticating to the Microsoft Graph. +
    + Add the following permissions: +
    + + + + + +
    Scopes + Microsoft Graph:
    + AppCatalog.ReadWrite.All
    + AuditLog.Read.All
    + SecurityEvents.Read.All
    + ServiceHealth.Read.All
    + ServiceMessage.Read.All
    + Sites.Read.All
    + User.Read
    +
    + Office 365 Management APIs:
    + ActivityFeed.Read
    + ServiceHealth.Read
    +
    + SharePoint:
    + AllSites.FullControl
    + User.ReadWrite.All
    +
    +
    +
  • +
  • Go to Overview page and note down the Application (client) ID and Directory (tenant) ID
  • +
  • Click on the Sign in to Microsoft 365 and provide the noted down Client Id and Tenant Id
  • +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/webview/view/components/gallery/GalleryView.tsx b/src/webview/view/components/gallery/GalleryView.tsx index 73c73bc..5468f18 100644 --- a/src/webview/view/components/gallery/GalleryView.tsx +++ b/src/webview/view/components/gallery/GalleryView.tsx @@ -19,21 +19,23 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea const [spfxVersions, setSPFxVersions] = useLocalStorage('spfxVersions', []); const [showOnlyScenarios, setShowOnlyScenarios] = useLocalStorage('showOnlyScenarios', false); const [componentTypes, setComponentTypes] = useLocalStorage('componentTypes', []); + const [extensionTypes, setExtensionTypes] = useLocalStorage('extensionTypes', []); + const [isExtensionSelected, setIsExtensionSelected] = useLocalStorage('isExtensionSelected', false); const onSearchTextboxChange = (event: any) => { const input: string = event.target.value; setQuery(input); - search(input, componentTypes ?? [], spfxVersions ?? [], showOnlyScenarios); + search(input, componentTypes ?? [], spfxVersions ?? [], extensionTypes ?? [], showOnlyScenarios); }; const onClearTextboxChange = () => { setQuery(''); - search('', componentTypes ?? [], spfxVersions ?? [], showOnlyScenarios); + search('', componentTypes ?? [], spfxVersions ?? [], extensionTypes ?? [], showOnlyScenarios); }; const onFilterOnlyScenariosChange = () => { setShowOnlyScenarios(!showOnlyScenarios); - search(query, componentTypes ?? [], spfxVersions ?? [], !showOnlyScenarios); + search(query, componentTypes ?? [], spfxVersions ?? [], extensionTypes ?? [], !showOnlyScenarios); }; const onFilterBySPFxVersionChange = (event: any, option?: IDropdownOption) => { @@ -51,7 +53,7 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea setSelectedFilters(removedFilter); } setSPFxVersions(spfxVersionsInput); - search(query, componentTypes ?? [], spfxVersionsInput, showOnlyScenarios); + search(query, componentTypes ?? [], spfxVersionsInput, extensionTypes ?? [], showOnlyScenarios); }; const onRemoveFilterBySPFxVersion = (key: string) => { @@ -63,6 +65,10 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea }; const onFilterByComponentTypeChange = (event: any, option?: IDropdownOption) => { + if (option?.key === 'extension') { + setIsExtensionSelected(prevState => !prevState); + } + let componentTypesInput: string[] = []; if (option?.selected) { componentTypesInput = [...componentTypes ?? [], option.key as string]; @@ -73,11 +79,38 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea }]); } else { componentTypesInput = componentTypes?.filter(componentType => componentType !== option?.key) ?? []; - const removedFilter = selectedFilters.filter(filter => filter.key !== option?.key); + let removedFilter = selectedFilters.filter(filter => filter.key !== option?.key); + if (option?.key === 'extension') { + setExtensionTypes([]); + removedFilter = removedFilter.filter(filter => filter.kind !== 'extensionType'); + } setSelectedFilters(removedFilter); } setComponentTypes(componentTypesInput); - search(query, componentTypesInput, spfxVersions ?? [], showOnlyScenarios); + search(query, componentTypesInput, spfxVersions ?? [], extensionTypes ?? [], showOnlyScenarios); + }; + + const onRemoveFilterByExtensionType = (key: string) => { + onFilterByExtensionTypeChange(null, { key: key, text: key, selected: false }); + }; + + const onFilterByExtensionTypeChange = (event: any, option?: IDropdownOption) => { + let extensionTypesInput: string[] = []; + + if (option?.selected) { + extensionTypesInput = [...extensionTypes ?? [], option.key as string]; + setSelectedFilters([...selectedFilters, { + key: option.key as string, + text: option.text as string, + kind: 'extensionType' + }]); + } else { + extensionTypesInput = extensionTypes?.filter(componentType => componentType !== option?.key) ?? []; + const removedFilter = selectedFilters.filter(filter => filter.key !== option?.key); + setSelectedFilters(removedFilter); + } + setExtensionTypes(extensionTypesInput); + search(query, componentTypes ?? [], spfxVersions ?? [], extensionTypesInput, showOnlyScenarios); }; const getSPFxVersions = (): IDropdownOption[] => { @@ -95,7 +128,7 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea useEffect(() => { if (samples !== undefined) { setShowOnlyScenarios(showOnlyScenarios); - search(query, componentTypes ?? [], spfxVersions ?? [], showOnlyScenarios); + search(query, componentTypes ?? [], spfxVersions ?? [], extensionTypes ?? [], showOnlyScenarios); } }, [samples]); @@ -104,9 +137,15 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea setSelectedFilters([]); setSPFxVersions([]); setComponentTypes([]); + setExtensionTypes([]); setShowOnlyScenarios(false); setQuery(''); - search('', [], [], false); + search('', [], [], [], false); + setIsExtensionSelected(false); + }; + + const onClearExtensionTypes = () => { + setExtensionTypes([]); }; return ( @@ -125,15 +164,17 @@ export const GalleryView: React.FunctionComponent = ({ }: Rea onFilterBySPFxVersionChange={(event, option) => onFilterBySPFxVersionChange(event, option)} onFilterByComponentTypeChange={(event, option) => onFilterByComponentTypeChange(event, option)} onFilterOnlyScenariosChange={() => onFilterOnlyScenariosChange()} + onFilterByExtensionTypeChange={(event, option) => onFilterByExtensionTypeChange(event, option)} initialQuery={query} selectedFilters={selectedFilters} onRemoveFilterBySPFxVersion={onRemoveFilterBySPFxVersion} onRemoveFilterByComponentType={onRemoveFilterByComponentType} + onRemoveFilterByExtensionType={onRemoveFilterByExtensionType} clearAllFilters={clearFilters} onClearTextboxChange={onClearTextboxChange} showOnlyScenarios={showOnlyScenarios} - spfxVersions={getSPFxVersions()} /> - + spfxVersions={getSPFxVersions()} + isExtensionSelected={isExtensionSelected} /> { samples.length === 0 && ( diff --git a/src/webview/view/components/gallery/SearchBar.tsx b/src/webview/view/components/gallery/SearchBar.tsx index 577b6bc..5c000cd 100644 --- a/src/webview/view/components/gallery/SearchBar.tsx +++ b/src/webview/view/components/gallery/SearchBar.tsx @@ -13,23 +13,26 @@ export interface ISearchBarProps { onFilterBySPFxVersionChange: (event: any, option?: IDropdownOption) => void; onFilterByComponentTypeChange: (event: any, option?: IDropdownOption) => void; onFilterOnlyScenariosChange: (event: any) => void; + onFilterByExtensionTypeChange: (event: any, option?: IDropdownOption) => void; initialQuery?: string; spfxVersions: IDropdownOption[]; selectedFilters: ISelectedFilter[]; onRemoveFilterBySPFxVersion: (key: string) => void; onRemoveFilterByComponentType: (key: string) => void; + onRemoveFilterByExtensionType: (key: string) => void; clearAllFilters: () => void; onClearTextboxChange: () => void; showOnlyScenarios: boolean; + isExtensionSelected: boolean; } export interface ISelectedFilter { key: string | null; text: string; - kind: 'spfxVersion' | 'componentType' + kind: 'spfxVersion' | 'componentType' | 'extensionType'; } -export const SearchBar: React.FunctionComponent = ({ onSearchTextboxChange, onFilterBySPFxVersionChange, onFilterByComponentTypeChange, onFilterOnlyScenariosChange, initialQuery, spfxVersions, selectedFilters, onRemoveFilterByComponentType, onRemoveFilterBySPFxVersion, clearAllFilters, onClearTextboxChange, showOnlyScenarios }: React.PropsWithChildren) => { +export const SearchBar: React.FunctionComponent = ({ onSearchTextboxChange, onFilterBySPFxVersionChange, onFilterByComponentTypeChange, onFilterOnlyScenariosChange, onFilterByExtensionTypeChange, initialQuery, spfxVersions, selectedFilters, onRemoveFilterByComponentType, onRemoveFilterBySPFxVersion, onRemoveFilterByExtensionType, clearAllFilters, onClearTextboxChange, showOnlyScenarios, isExtensionSelected }: React.PropsWithChildren) => { const [query, setQuery] = useState(initialQuery ?? ''); const [debouncedQuery, setDebounceQuery] = useDebounce(query, 300); @@ -65,6 +68,26 @@ export const SearchBar: React.FunctionComponent = ({ onSearchTe return options; }; + const getExtensionTypeOptions = (): IDropdownOption[] => { + const extensionTypes: IDropdownOption[] = [ + { key: 'ListViewCommandSet', text: 'List view commandset' }, + { key: 'ApplicationCustomizer', text: 'Application customizer' }, + { key: 'FieldCustomizer', text: 'Field customizer' }, + { key: 'FormCustomizer', text: 'Form customizer' } + ]; + + selectedFilters.forEach(filter => { + if (filter.kind === 'extensionType') { + const matchingOption = extensionTypes.find(extensionType => extensionType.key === filter.key); + if (matchingOption) { + matchingOption.selected = true; + } + } + }); + + return extensionTypes; + }; + const clearQueryAndTextbox = () => { setQuery(''); onClearTextboxChange(); @@ -76,10 +99,19 @@ export const SearchBar: React.FunctionComponent = ({ onSearchTe }; const componentTypes = getComponentTypeOptions(); + const extensionTypes = getExtensionTypeOptions(); + + const handleComponentTypeChange = (event: any, option?: IDropdownOption) => { + onFilterByComponentTypeChange(event, option); + }; + + const handleExtensionTypeChange = (event: any, option?: IDropdownOption) => { + onFilterByExtensionTypeChange(event, option); + }; return (
-
+
@@ -91,8 +123,13 @@ export const SearchBar: React.FunctionComponent = ({ onSearchTe
- +
+ {isExtensionSelected && ( +
+ +
+ )}
show only scenarios
@@ -124,6 +161,18 @@ export const SearchBar: React.FunctionComponent = ({ onSearchTe
); + } else if (filter.kind === 'extensionType') { + return ( + ); } return ( diff --git a/src/webview/view/hooks/useSamples.tsx b/src/webview/view/hooks/useSamples.tsx index 2aee059..892f027 100644 --- a/src/webview/view/hooks/useSamples.tsx +++ b/src/webview/view/hooks/useSamples.tsx @@ -6,7 +6,7 @@ import { Sample } from '../../../models'; const SAMPLES_URL = 'https://raw.githubusercontent.com/pnp/vscode-viva/main/data/sp-dev-fx-samples.json'; -export default function useSamples(): [Sample[], string[], ((query: string, componentTypes: string[], spfxVersions: string[], showOnlyScenarios: boolean) => void)] { +export default function useSamples(): [Sample[], string[], ((query: string, componentTypes: string[], spfxVersions: string[], extensionTypes: string[], showOnlyScenarios: boolean) => void)] { const [allSamples, setAllSamples] = useState(undefined); const [samples, setSamples] = useState(undefined); const state = Messenger.getState() as any || {}; @@ -67,8 +67,11 @@ export default function useSamples(): [Sample[], string[], ((query: string, comp }); }, [allSamples]); - const search = (query: string, componentTypes: string[], spfxVersions: string[], showOnlyScenarios: boolean) => { + const search = (query: string, componentTypes: string[], spfxVersions: string[], extensionTypes: string[], showOnlyScenarios: boolean) => { const currentSamples: Sample[] = state['samples']; + if (!currentSamples) { + return; + } const samplesByTitle: Sample[] = currentSamples!.filter((sample: Sample) => sample.title.toString().toLowerCase().includes(query.toLowerCase())); const samplesByTag: Sample[] = currentSamples!.filter((sample: Sample) => sample.tags.some(tag => tag.toString().toLowerCase().includes(query.toLowerCase()))); const samplesByAuthor: Sample[] = currentSamples!.filter((sample: Sample) => sample.authors.some(author => author.name && author.name.toString().toLowerCase().includes(query.toLowerCase()))); @@ -90,7 +93,12 @@ export default function useSamples(): [Sample[], string[], ((query: string, comp filteredSamplesBySPFxVersion = filteredSamplesByComponentType.filter((sample: Sample) => spfxVersions.includes(sample.version)); } - setSamples(filteredSamplesBySPFxVersion); + let filteredSamplesByExtension = filteredSamplesBySPFxVersion; + if (extensionTypes.length > 0 && componentTypes.includes('extension')) { + filteredSamplesByExtension = filteredSamplesBySPFxVersion.filter((sample: Sample) => extensionTypes.includes(sample.extensionType)); + } + + setSamples(filteredSamplesByExtension); }; return [samples!, versions, search]; diff --git a/src/webview/view/index.css b/src/webview/view/index.css index 8968b8e..192b2de 100644 --- a/src/webview/view/index.css +++ b/src/webview/view/index.css @@ -111,4 +111,8 @@ .bg-vscode{ background: var(--vscode-editor-background); +} + +.entraAppReg_manual_steps { + list-style: disc; } \ No newline at end of file diff --git a/src/webview/view/index.tsx b/src/webview/view/index.tsx index 571b151..d55e712 100644 --- a/src/webview/view/index.tsx +++ b/src/webview/view/index.tsx @@ -10,6 +10,7 @@ export const routeEntries: { [routeKey: string]: string } = { [WebViewType.samplesGallery]: WebViewTypes.find(type => type.value === WebViewType.samplesGallery)?.homePageUrl as string, [WebViewType.workflowForm]: WebViewTypes.find(type => type.value === WebViewType.workflowForm)?.homePageUrl as string, [WebViewType.scaffoldForm]: WebViewTypes.find(type => type.value === WebViewType.scaffoldForm)?.homePageUrl as string, + [WebViewType.registerEntraAppRegistration]: WebViewTypes.find(type => type.value === WebViewType.registerEntraAppRegistration)?.homePageUrl as string, }; const elm = document.querySelector('#root');