Merge branch 'pnp:main' into Birthdays

This commit is contained in:
João Mendes 2023-02-01 19:34:20 +00:00 committed by GitHub
commit 0f05ac3a43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 42290 additions and 9957 deletions

View File

@ -27364,9 +27364,9 @@
}
},
"ua-parser-js": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -23527,9 +23527,9 @@
}
},
"ua-parser-js": {
"version": "0.7.19",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz",
"integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -8407,8 +8407,8 @@ typescript@~2.4.1:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.4.2.tgz#f8395f85d459276067c988aa41837a8f82870844"
ua-parser-js@^0.7.18, ua-parser-js@^0.7.9:
version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
version "0.7.33"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
uglify-js@3.0.x:
version "3.0.24"

View File

@ -25584,9 +25584,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.19",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz",
"integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -25759,9 +25759,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.28",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz",
"integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -17029,9 +17029,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.32",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz",
"integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==",
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==",
"dev": true
},
"uglify-js": {

View File

@ -24166,9 +24166,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.21",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",
"integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -23934,9 +23934,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.20",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.20.tgz",
"integrity": "sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -5706,9 +5706,9 @@
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"version": "2.6.8",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
"integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
"requires": {
"whatwg-url": "^5.0.0"
}
@ -5958,9 +5958,9 @@
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"version": "2.6.8",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
"integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
"requires": {
"whatwg-url": "^5.0.0"
}
@ -29161,9 +29161,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -22488,9 +22488,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -26509,9 +26509,9 @@
}
},
"ua-parser-js": {
"version": "0.7.28",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz",
"integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -9,7 +9,7 @@ This demos the use of the new [Fluent UI version 9](https://github.com/microsoft
## Compatibility
![SPFx 1.16.0](https://img.shields.io/badge/SPFx-1.16-green.svg)
![SPFx 1.16.1](https://img.shields.io/badge/SPFx-1.16.1-green.svg)
![Node.js v16](https://img.shields.io/badge/Node.js-v16-green.svg)
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
@ -46,7 +46,7 @@ Version|Date|Comments
-------|----|--------
1.0|April 20, 2022|Initial release
1.0.1|November 14, 2022|Updated to SPFx 15, latest Fluent UI 9, shim based theme mapping
1.0.2|November 16, 2022|
1.0.2|January 18, 2023|Updated SPFx 16.1
- Clone this repository (or [download this solution as a .ZIP file](https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-fluentui-9) then unzip it)
- From your command-line, change your current directory to the directory containing this sample (`react-fluentui-9`, located under `samples`)

View File

@ -10,7 +10,7 @@
"description"
],
"creationDateTime": "2022-04-20",
"updateDateTime": "2022-11-16",
"updateDateTime": "2023-01-18",
"products": [
"SharePoint"
],
@ -21,7 +21,7 @@
},
{
"key": "SPFX-VERSION",
"value": "1.16"
"value": "1.16.1"
}
],
"thumbnails": [

File diff suppressed because it is too large Load Diff

View File

@ -12,22 +12,22 @@
"test": "gulp test"
},
"dependencies": {
"@fluentui/react-components": "^9.7.0",
"@microsoft/sp-core-library": "1.16.0",
"@microsoft/sp-lodash-subset": "1.16.0",
"@microsoft/sp-office-ui-fabric-core": "1.16.0",
"@microsoft/sp-property-pane": "1.16.0",
"@microsoft/sp-webpart-base": "1.16.0",
"@fluentui/react-components": "^9.10.1",
"@microsoft/sp-core-library": "1.16.1",
"@microsoft/sp-lodash-subset": "1.16.1",
"@microsoft/sp-office-ui-fabric-core": "1.16.1",
"@microsoft/sp-property-pane": "1.16.1",
"@microsoft/sp-webpart-base": "1.16.1",
"react": "17.0.1",
"react-dom": "17.0.1",
"tslib": "2.3.1"
},
"devDependencies": {
"@microsoft/eslint-config-spfx": "1.16.0",
"@microsoft/eslint-plugin-spfx": "1.16.0",
"@microsoft/eslint-config-spfx": "1.16.1",
"@microsoft/eslint-plugin-spfx": "1.16.1",
"@microsoft/rush-stack-compiler-4.5": "0.2.2",
"@microsoft/sp-build-web": "1.16.0",
"@microsoft/sp-module-interfaces": "1.16.0",
"@microsoft/sp-build-web": "1.16.1",
"@microsoft/sp-module-interfaces": "1.16.1",
"@rushstack/eslint-config": "2.5.1",
"@types/react": "17.0.45",
"@types/react-dom": "17.0.17",

View File

@ -154,6 +154,8 @@ const mapAliasColors = (palette: Partial<IPalette>, inverted: boolean): ColorTok
colorNeutralShadowKeyDarker: 'rgba(0,0,0,0.24)',
colorBrandShadowAmbient: 'rgba(0,0,0,0.30)',
colorBrandShadowKey: 'rgba(0,0,0,0.25)',
colorNeutralStencil1Alpha: webLightTheme.colorNeutralStencil1Alpha,
colorNeutralStencil2Alpha: webLightTheme.colorNeutralStencil2Alpha,
};
};

View File

@ -2,7 +2,7 @@
"@microsoft/generator-sharepoint": {
"plusBeta": false,
"isCreatingSolution": true,
"version": "1.15.2",
"version": "1.16.1",
"libraryName": "group-membership-manager",
"libraryId": "2a487e9a-b62a-484a-9bc0-e78b65550b61",
"environment": "spo",
@ -11,6 +11,9 @@
"solutionShortDescription": "Group Membership Manager description",
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"componentType": "webpart"
"componentType": "webpart",
"sdkVersions": {
"@microsoft/teams-js": "2.4.1"
}
}
}

View File

@ -8,7 +8,7 @@ This app is an example of managing the membership of a group you own including t
## Compatibility
![SPFx 1.15.2](https://img.shields.io/badge/SPFx-1.15.2-green.svg)
![SPFx 1.16.1](https://img.shields.io/badge/SPFx-1.16.1-green.svg)
![Node.js v16](https://img.shields.io/badge/Node.js-v16-green.svg)
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
@ -35,6 +35,7 @@ This app is an example of managing the membership of a group you own including t
| Version | Date | Comments |
| ------- | ---------------- | --------------- |
| 1.0 | August 25, 2022 | Initial release |
| 1.1| | Jan 18, 2022 | Updated to SPFx 1.16.1 |
## Minimal Path to Awesome

View File

@ -10,7 +10,7 @@
"This app is an example of managing the membership of a group you own including the owners of the group as well as using FluentUI v9"
],
"creationDateTime": "2022-08-25",
"updateDateTime": "2022-08-25",
"updateDateTime": "2023-01-18",
"products": [
"SharePoint"
],
@ -21,7 +21,7 @@
},
{
"key": "SPFX-VERSION",
"value": "1.14"
"value": "1.16.1"
}
],
"thumbnails": [

View File

@ -3,7 +3,7 @@
"solution": {
"name": "group-membership-manager-client-side-solution",
"id": "2a487e9a-b62a-484a-9bc0-e78b65550b61",
"version": "1.0.0.0",
"version": "1.1.0.0",
"includeClientSideAssets": true,
"title": "Group Membership Manager",
"skipFeatureDeployment": true,
@ -13,7 +13,7 @@
"websiteUrl": "https://nbdev.uk",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": "Undefined-1.15.2"
"mpnId": "Undefined-1.16.1"
},
"metadata": {
"shortDescription": {

File diff suppressed because it is too large Load Diff

View File

@ -3,32 +3,36 @@
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=16.13.0 <17.0.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@fluentui/react-components": "^9.3.1",
"@microsoft/sp-core-library": "1.15.2",
"@microsoft/sp-lodash-subset": "1.15.2",
"@microsoft/sp-office-ui-fabric-core": "1.15.2",
"@microsoft/sp-property-pane": "1.15.2",
"@microsoft/sp-webpart-base": "1.15.2",
"react": "16.13.1",
"react-dom": "16.13.1",
"@fluentui/react-components": "^9.10.1",
"@microsoft/sp-component-base": "^1.16.1",
"@microsoft/sp-core-library": "1.16.1",
"@microsoft/sp-lodash-subset": "1.16.1",
"@microsoft/sp-office-ui-fabric-core": "1.16.1",
"@microsoft/sp-property-pane": "1.16.1",
"@microsoft/sp-webpart-base": "1.16.1",
"react": "17.0.1",
"react-dom": "17.0.1",
"tslib": "2.3.1"
},
"devDependencies": {
"@microsoft/eslint-config-spfx": "1.15.2",
"@microsoft/eslint-plugin-spfx": "1.15.2",
"@microsoft/microsoft-graph-types": "^2.24.0",
"@microsoft/eslint-config-spfx": "1.16.1",
"@microsoft/eslint-plugin-spfx": "1.16.1",
"@microsoft/microsoft-graph-types": "^2.25.0",
"@microsoft/rush-stack-compiler-4.5": "0.2.2",
"@microsoft/sp-build-web": "1.15.2",
"@microsoft/sp-module-interfaces": "1.15.2",
"@microsoft/sp-build-web": "1.16.1",
"@microsoft/sp-module-interfaces": "1.16.1",
"@rushstack/eslint-config": "2.5.1",
"@types/react": "16.9.51",
"@types/react-dom": "16.9.8",
"@types/react": "17.0.45",
"@types/react-dom": "17.0.17",
"@types/webpack-env": "~1.15.2",
"ajv": "^6.12.5",
"eslint-plugin-react-hooks": "^4.6.0",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,173 @@
import { IPalette, ITheme as ThemeV8 } from '@microsoft/sp-component-base';
import { ColorTokens, Theme as ThemeV9, webLightTheme } from '@fluentui/react-components';
import { blackAlpha, whiteAlpha, grey } from './themeDuplicates';
/**
* Creates v9 color tokens from a v8 palette.
* https://github.com/microsoft/fluentui/blob/master/apps/public-docsite-v9/src/shims/ThemeShim/v9ThemeShim.ts
*/
const mapAliasColors = (palette: Partial<IPalette>, inverted: boolean): ColorTokens => {
return {
colorNeutralForeground1: palette.neutralPrimary || webLightTheme.colorNeutralForeground1,
colorNeutralForeground1Hover: palette.neutralPrimary || webLightTheme.colorNeutralForeground1Hover,
colorNeutralForeground1Pressed: palette.neutralPrimary || webLightTheme.colorNeutralForeground1Pressed,
colorNeutralForeground1Selected: palette.neutralPrimary || webLightTheme.colorNeutralForeground1Selected,
colorNeutralForeground2: palette.neutralSecondary || webLightTheme.colorNeutralForeground2,
colorNeutralForeground2Hover: palette.neutralPrimary || webLightTheme.colorNeutralForeground2Hover,
colorNeutralForeground2Pressed: palette.neutralPrimary || webLightTheme.colorNeutralForeground2Pressed,
colorNeutralForeground2Selected: palette.neutralPrimary || webLightTheme.colorNeutralForeground2Selected,
colorNeutralForeground2BrandHover: palette.themePrimary || webLightTheme.colorNeutralForeground2BrandHover,
colorNeutralForeground2BrandPressed: palette.themeDarkAlt || webLightTheme.colorNeutralForeground2BrandPressed,
colorNeutralForeground2BrandSelected: palette.themePrimary || webLightTheme.colorNeutralForeground2BrandSelected,
colorNeutralForeground3: palette.neutralTertiary || webLightTheme.colorNeutralForeground3,
colorNeutralForeground3Hover: palette.neutralSecondary || webLightTheme.colorNeutralForeground3Hover,
colorNeutralForeground3Pressed: palette.neutralSecondary || webLightTheme.colorNeutralForeground3Pressed,
colorNeutralForeground3Selected: palette.neutralSecondary || webLightTheme.colorNeutralForeground3Selected,
colorNeutralForeground3BrandHover: palette.themePrimary || webLightTheme.colorNeutralForeground3BrandHover,
colorNeutralForeground3BrandPressed: palette.themeDarkAlt || webLightTheme.colorNeutralForeground3BrandPressed,
colorNeutralForeground3BrandSelected: palette.themePrimary || webLightTheme.colorNeutralForeground3BrandSelected,
colorNeutralForeground4: palette.neutralQuaternary || webLightTheme.colorNeutralForeground4,
colorNeutralForegroundDisabled: palette.neutralTertiaryAlt || webLightTheme.colorNeutralForegroundDisabled,
colorNeutralForegroundInvertedDisabled: whiteAlpha[40],
colorBrandForegroundLink: palette.themeDarkAlt || webLightTheme.colorBrandForegroundLink,
colorBrandForegroundLinkHover: palette.themeDark || webLightTheme.colorBrandForegroundLinkHover,
colorBrandForegroundLinkPressed: palette.themeDarker || webLightTheme.colorBrandForegroundLinkPressed,
colorBrandForegroundLinkSelected: palette.themeDarkAlt || webLightTheme.colorBrandForegroundLinkSelected,
colorNeutralForeground2Link: palette.neutralSecondary || webLightTheme.colorNeutralForeground2Link,
colorNeutralForeground2LinkHover: palette.neutralPrimary || webLightTheme.colorNeutralForeground2LinkHover,
colorNeutralForeground2LinkPressed: palette.neutralPrimary || webLightTheme.colorNeutralForeground2LinkPressed,
colorNeutralForeground2LinkSelected: palette.neutralPrimary || webLightTheme.colorNeutralForeground2LinkSelected,
colorCompoundBrandForeground1: palette.themePrimary || webLightTheme.colorCompoundBrandForeground1,
colorCompoundBrandForeground1Hover: palette.themeDarkAlt || webLightTheme.colorCompoundBrandForeground1Hover,
colorCompoundBrandForeground1Pressed: palette.themeDark || webLightTheme.colorCompoundBrandForeground1Pressed,
colorBrandForeground1: palette.themePrimary || webLightTheme.colorBrandForeground1,
colorBrandForeground2: palette.themeDarkAlt || webLightTheme.colorBrandForeground2,
colorNeutralForeground1Static: palette.neutralPrimary || webLightTheme.colorNeutralForeground1Static,
colorNeutralForegroundInverted: palette.white || webLightTheme.colorNeutralForegroundInverted,
colorNeutralForegroundInvertedHover: palette.white || webLightTheme.colorNeutralForegroundInvertedHover,
colorNeutralForegroundInvertedPressed: palette.white || webLightTheme.colorNeutralForegroundInvertedPressed,
colorNeutralForegroundInvertedSelected: palette.white || webLightTheme.colorNeutralForegroundInvertedSelected,
colorNeutralForegroundOnBrand: palette.white || webLightTheme.colorNeutralForegroundOnBrand,
colorNeutralForegroundStaticInverted: palette.white || webLightTheme.colorNeutralForegroundStaticInverted,
colorNeutralForegroundInvertedLink: palette.white || webLightTheme.colorNeutralForegroundInvertedLink,
colorNeutralForegroundInvertedLinkHover: palette.white || webLightTheme.colorNeutralForegroundInvertedLinkHover,
colorNeutralForegroundInvertedLinkPressed: palette.white || webLightTheme.colorNeutralForegroundInvertedLinkPressed,
colorNeutralForegroundInvertedLinkSelected: palette.white || webLightTheme.colorNeutralForegroundInvertedLinkSelected,
colorNeutralForegroundInverted2: palette.white || webLightTheme.colorNeutralForegroundInverted2,
colorBrandForegroundInverted: palette.themeSecondary || webLightTheme.colorBrandForegroundInverted,
colorBrandForegroundInvertedHover: palette.themeTertiary || webLightTheme.colorBrandForegroundInvertedHover,
colorBrandForegroundInvertedPressed: palette.themeSecondary || webLightTheme.colorBrandForegroundInvertedPressed,
colorBrandForegroundOnLight: palette.themePrimary || webLightTheme.colorBrandForegroundOnLight,
colorBrandForegroundOnLightHover: palette.themeDarkAlt || webLightTheme.colorBrandForegroundOnLightHover,
colorBrandForegroundOnLightPressed: palette.themeDark || webLightTheme.colorBrandForegroundOnLightPressed,
colorBrandForegroundOnLightSelected: palette.themeDark || webLightTheme.colorBrandForegroundOnLightSelected,
colorNeutralBackground1: palette.white || webLightTheme.colorNeutralBackground1,
colorNeutralBackground1Hover: palette.neutralLighter || webLightTheme.colorNeutralBackground1Hover,
colorNeutralBackground1Pressed: palette.neutralQuaternaryAlt || webLightTheme.colorNeutralBackground1Pressed,
colorNeutralBackground1Selected: palette.neutralLight || webLightTheme.colorNeutralBackground1Selected,
colorNeutralBackground2: palette.neutralLighterAlt || webLightTheme.colorNeutralBackground2,
colorNeutralBackground2Hover: palette.neutralLighter || webLightTheme.colorNeutralBackground2Hover,
colorNeutralBackground2Pressed: palette.neutralQuaternaryAlt || webLightTheme.colorNeutralBackground2Pressed,
colorNeutralBackground2Selected: palette.neutralLight || webLightTheme.colorNeutralBackground2Selected,
colorNeutralBackground3: palette.neutralLighter || webLightTheme.colorNeutralBackground3,
colorNeutralBackground3Hover: palette.neutralLight || webLightTheme.colorNeutralBackground3Hover,
colorNeutralBackground3Pressed: palette.neutralQuaternary || webLightTheme.colorNeutralBackground3Pressed,
colorNeutralBackground3Selected: palette.neutralQuaternaryAlt || webLightTheme.colorNeutralBackground3Selected,
colorNeutralBackground4: palette.neutralLighter || webLightTheme.colorNeutralBackground4,
colorNeutralBackground4Hover: palette.neutralLighterAlt || webLightTheme.colorNeutralBackground4Hover,
colorNeutralBackground4Pressed: palette.neutralLighter || webLightTheme.colorNeutralBackground4Pressed,
colorNeutralBackground4Selected: palette.white || webLightTheme.colorNeutralBackground4Selected,
colorNeutralBackground5: palette.neutralLight || webLightTheme.colorNeutralBackground5,
colorNeutralBackground5Hover: palette.neutralLighter || webLightTheme.colorNeutralBackground5Hover,
colorNeutralBackground5Pressed: palette.neutralLighter || webLightTheme.colorNeutralBackground5Pressed,
colorNeutralBackground5Selected: palette.neutralLighterAlt || webLightTheme.colorNeutralBackground5Selected,
colorNeutralBackground6: palette.neutralLight || webLightTheme.colorNeutralBackground6,
colorNeutralBackgroundStatic: grey[20],
colorNeutralBackgroundInverted: palette.neutralSecondary || webLightTheme.colorNeutralBackgroundInverted,
colorSubtleBackground: 'transparent',
colorSubtleBackgroundHover: palette.neutralLighter || webLightTheme.colorSubtleBackgroundHover,
colorSubtleBackgroundPressed: palette.neutralQuaternaryAlt || webLightTheme.colorSubtleBackgroundPressed,
colorSubtleBackgroundSelected: palette.neutralLight || webLightTheme.colorSubtleBackgroundSelected,
colorSubtleBackgroundLightAlphaHover: inverted ? whiteAlpha[10] : whiteAlpha[80],
colorSubtleBackgroundLightAlphaPressed: inverted ? whiteAlpha[5] : whiteAlpha[50],
colorSubtleBackgroundLightAlphaSelected: 'transparent',
colorSubtleBackgroundInverted: 'transparent',
colorSubtleBackgroundInvertedHover: blackAlpha[10],
colorSubtleBackgroundInvertedPressed: blackAlpha[30],
colorSubtleBackgroundInvertedSelected: blackAlpha[20],
colorTransparentBackground: 'transparent',
colorTransparentBackgroundHover: 'transparent',
colorTransparentBackgroundPressed: 'transparent',
colorTransparentBackgroundSelected: 'transparent',
colorNeutralBackgroundDisabled: palette.neutralLighter || webLightTheme.colorNeutralBackgroundDisabled,
colorNeutralBackgroundInvertedDisabled: whiteAlpha[10],
colorNeutralStencil1: palette.neutralLight || webLightTheme.colorNeutralStencil1,
colorNeutralStencil2: palette.neutralLighterAlt || webLightTheme.colorNeutralStencil2,
colorBackgroundOverlay: blackAlpha[10],
colorScrollbarOverlay: blackAlpha[50],
colorBrandBackground: palette.themePrimary || webLightTheme.colorBrandBackground,
colorBrandBackgroundHover: palette.themeDarkAlt || webLightTheme.colorBrandBackgroundHover,
colorBrandBackgroundPressed: palette.themeDarker || webLightTheme.colorBrandBackgroundPressed,
colorBrandBackgroundSelected: palette.themeDark || webLightTheme.colorBrandBackgroundSelected,
colorCompoundBrandBackground: palette.themePrimary || webLightTheme.colorCompoundBrandBackground,
colorCompoundBrandBackgroundHover: palette.themeDarkAlt || webLightTheme.colorCompoundBrandBackgroundHover,
colorCompoundBrandBackgroundPressed: palette.themeDark || webLightTheme.colorCompoundBrandBackgroundPressed,
colorBrandBackgroundStatic: palette.themePrimary || webLightTheme.colorBrandBackgroundStatic,
colorBrandBackground2: palette.themeLighterAlt || webLightTheme.colorBrandBackground2,
colorBrandBackgroundInverted: palette.white || webLightTheme.colorBrandBackgroundInverted,
colorBrandBackgroundInvertedHover: palette.themeLighterAlt || webLightTheme.colorBrandBackgroundInvertedHover,
colorBrandBackgroundInvertedPressed: palette.themeLight || webLightTheme.colorBrandBackgroundInvertedPressed,
colorBrandBackgroundInvertedSelected: palette.themeLighter || webLightTheme.colorBrandBackgroundInvertedSelected,
colorNeutralStrokeAccessible: palette.neutralSecondary || webLightTheme.colorNeutralStrokeAccessible,
colorNeutralStrokeAccessibleHover: palette.neutralSecondary || webLightTheme.colorNeutralStrokeAccessibleHover,
colorNeutralStrokeAccessiblePressed: palette.neutralSecondary || webLightTheme.colorNeutralStrokeAccessiblePressed,
colorNeutralStrokeAccessibleSelected: palette.themePrimary || webLightTheme.colorNeutralStrokeAccessibleSelected,
colorNeutralStroke1: palette.neutralQuaternary || webLightTheme.colorNeutralStroke1,
colorNeutralStroke1Hover: palette.neutralTertiaryAlt || webLightTheme.colorNeutralStroke1Hover,
colorNeutralStroke1Pressed: palette.neutralTertiaryAlt || webLightTheme.colorNeutralStroke1Pressed,
colorNeutralStroke1Selected: palette.neutralTertiaryAlt || webLightTheme.colorNeutralStroke1Selected,
colorNeutralStroke2: palette.neutralQuaternaryAlt || webLightTheme.colorNeutralStroke2,
colorNeutralStroke3: palette.neutralLighter || webLightTheme.colorNeutralStroke3,
colorNeutralStrokeOnBrand: palette.white || webLightTheme.colorNeutralStrokeOnBrand,
colorNeutralStrokeOnBrand2: palette.white || webLightTheme.colorNeutralStrokeOnBrand2,
colorNeutralStrokeOnBrand2Hover: palette.white || webLightTheme.colorNeutralStrokeOnBrand2Hover,
colorNeutralStrokeOnBrand2Pressed: palette.white || webLightTheme.colorNeutralStrokeOnBrand2Pressed,
colorNeutralStrokeOnBrand2Selected: palette.white || webLightTheme.colorNeutralStrokeOnBrand2Selected,
colorBrandStroke1: palette.themePrimary || webLightTheme.colorBrandStroke1,
colorBrandStroke2: palette.themeLight || webLightTheme.colorBrandStroke2,
colorCompoundBrandStroke: palette.themePrimary || webLightTheme.colorCompoundBrandStroke,
colorCompoundBrandStrokeHover: palette.themeDarkAlt || webLightTheme.colorCompoundBrandStrokeHover,
colorCompoundBrandStrokePressed: palette.themeDark || webLightTheme.colorCompoundBrandStrokePressed,
colorNeutralStrokeDisabled: palette.neutralQuaternaryAlt || webLightTheme.colorNeutralStrokeDisabled,
colorNeutralStrokeInvertedDisabled: whiteAlpha[40],
colorTransparentStroke: 'transparent',
colorTransparentStrokeInteractive: 'transparent',
colorTransparentStrokeDisabled: 'transparent',
colorStrokeFocus1: palette.white || webLightTheme.colorStrokeFocus1,
colorStrokeFocus2: palette.black || webLightTheme.colorStrokeFocus2,
colorNeutralShadowAmbient: 'rgba(0,0,0,0.12)',
colorNeutralShadowKey: 'rgba(0,0,0,0.14)',
colorNeutralShadowAmbientLighter: 'rgba(0,0,0,0.06)',
colorNeutralShadowKeyLighter: 'rgba(0,0,0,0.07)',
colorNeutralShadowAmbientDarker: 'rgba(0,0,0,0.20)',
colorNeutralShadowKeyDarker: 'rgba(0,0,0,0.24)',
colorBrandShadowAmbient: 'rgba(0,0,0,0.30)',
colorBrandShadowKey: 'rgba(0,0,0,0.25)',
colorNeutralStencil1Alpha: webLightTheme.colorNeutralStencil1Alpha,
colorNeutralStencil2Alpha: webLightTheme.colorNeutralStencil2Alpha,
};
};
/**
* Creates a v9 theme from a v8 theme.
* You can optional pass a base v9 theme; otherwise webLightTheme is used.
*/
export const createv9Theme = (themeV8: ThemeV8, baseThemeV9?: ThemeV9): ThemeV9 => {
const baseTheme = baseThemeV9 ?? webLightTheme;
return {
...baseTheme,
...mapAliasColors(themeV8.palette, themeV8.isInverted)
};
};

View File

@ -1,15 +1,15 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { IPropertyPaneConfiguration } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import GroupMembershipManager from './components/GroupMembershipManager';
import { IGroupMembershipManagerProps } from './components/IGroupMembershipManagerProps';
import { FluentProvider, FluentProviderProps, teamsDarkTheme, teamsLightTheme, webLightTheme, webDarkTheme, Theme } from '@fluentui/react-components';
import { createv9Theme } from '../../shims/v9ThemeShim';
export enum AppMode {
SharePoint, SharePointLocal, Teams, TeamsLocal
SharePoint, Teams, Office, Outlook
}
export default class GroupMembershipManagerWebPart extends BaseClientSideWebPart<{}> {
@ -17,9 +17,16 @@ export default class GroupMembershipManagerWebPart extends BaseClientSideWebPart
private _appMode: AppMode = AppMode.SharePoint;
private _theme: Theme = webLightTheme;
protected onInit(): Promise<void> {
//on initalizational set the App Mode
this._appMode = !!this.context.sdks.microsoftTeams ? (this.context.isServedFromLocalhost ? AppMode.TeamsLocal : AppMode.Teams) : (this.context.isServedFromLocalhost) ? AppMode.SharePointLocal : AppMode.SharePoint;
protected async onInit(): Promise<void> {
if (!!this.context.sdks.microsoftTeams) {
const teamsContext = await this.context.sdks.microsoftTeams.teamsJs.app.getContext();
switch (teamsContext.app.host.name.toLowerCase()) {
case 'teams': this._appMode = AppMode.Teams; break;
case 'office': this._appMode = AppMode.Office; break;
case 'outlook': this._appMode = AppMode.Outlook; break;
default: throw new Error('Unknown host');
}
} else this._appMode = AppMode.SharePoint;
return super.onInit();
}
@ -32,14 +39,16 @@ export default class GroupMembershipManagerWebPart extends BaseClientSideWebPart
context: this.context
}
);
//wrap the component with the Fluent UI 9 Provider.
const fluentElement: React.ReactElement<FluentProviderProps> = React.createElement(
FluentProvider,
{
theme : this._appMode === AppMode.Teams || this._appMode === AppMode.TeamsLocal ?
this._isDarkTheme ? teamsDarkTheme : teamsLightTheme :
this._isDarkTheme ? webDarkTheme : this._theme
theme: this._appMode === AppMode.Teams ?
this._isDarkTheme ? teamsDarkTheme : teamsLightTheme :
this._appMode === AppMode.SharePoint ?
this._isDarkTheme ? webDarkTheme : this._theme :
this._isDarkTheme ? webDarkTheme : webLightTheme
},
element
);
@ -50,30 +59,9 @@ export default class GroupMembershipManagerWebPart extends BaseClientSideWebPart
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) return;
this._isDarkTheme = !!currentTheme.isInverted;
//if the app mode is sharepoint, adjust the fluent ui 9 web light theme to use the sharepoint theme color, teams/dark mode should be fine on default
if (this._appMode === AppMode.SharePoint || this._appMode === AppMode.SharePointLocal) {
this._theme = {...webLightTheme,
colorBrandBackground: currentTheme.palette.themePrimary,
colorBrandBackgroundHover: currentTheme.palette.themeDark,
colorBrandBackgroundPressed: currentTheme.palette.themeDarker,
colorCompoundBrandForeground1: currentTheme.palette.themePrimary,
colorCompoundBrandForeground1Hover: currentTheme.palette.themeDark,
colorCompoundBrandForeground1Pressed: currentTheme.palette.themeDarker,
colorNeutralForeground2BrandHover: currentTheme.palette.themeSecondary,
colorNeutralForeground2BrandPressed: currentTheme.palette.themeDarkAlt,
colorNeutralForeground2BrandSelected: currentTheme.palette.themeDarkAlt,
colorBrandForeground1: currentTheme.palette.themeSecondary,
colorBrandStroke1: currentTheme.palette.themePrimary,
colorBrandStroke2: currentTheme.palette.themeSecondary,
colorCompoundBrandStroke: currentTheme.palette.themePrimary,
colorCompoundBrandStrokeHover: currentTheme.palette.themeSecondary,
colorCompoundBrandStrokePressed: currentTheme.palette.themeDarkAlt,
colorCompoundBrandBackground: currentTheme.palette.themePrimary,
colorCompoundBrandBackgroundHover: currentTheme.palette.themeDark,
colorCompoundBrandBackgroundPressed: currentTheme.palette.themeDarker,
};
if (this._appMode === AppMode.SharePoint) {
this._theme = createv9Theme(currentTheme, webLightTheme);
}
}
@ -84,11 +72,4 @@ export default class GroupMembershipManagerWebPart extends BaseClientSideWebPart
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
]
};
}
}

View File

@ -2,15 +2,14 @@ import * as React from 'react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import styles from './GroupMembershipManager.module.scss';
import * as strings from 'GroupMembershipManagerWebPartStrings'
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Alert } from "@fluentui/react-components/unstable";
import { Button, Checkbox, Divider, Input, Label, Spinner, useId } from "@fluentui/react-components";
import { Alert } from "@fluentui/react-components/unstable";
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Button, Checkbox, Divider, Input, Label, Spinner, useId, DialogContent } from "@fluentui/react-components";
import { PersonAddRegular } from '@fluentui/react-icons';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import SPFxPeopleCard from './SPFxPeopleCard';
import { GraphError } from '@microsoft/microsoft-graph-client';
import { GraphError } from '@microsoft/microsoft-graph-clientv1';
export enum AddUserMode { Member, Owner }
enum rState { Idle, Running, Error, Completed }
@ -44,75 +43,74 @@ export default function AddUser({ Group, Mode, context, onCompleted }: Props): R
"members@odata.bind": users.map(m => `https://graph.microsoft.com/v1.0/directoryObjects/${m.id}`)
} : {
"owners@odata.bind": users.map(m => `https://graph.microsoft.com/v1.0/directoryObjects/${m.id}`)
}, (e: GraphError, response ) => {
}, (e: GraphError) => {
if (e) throw e.message;
else setRunning(rState.Completed)
}).catch(handleError);
} catch (error) {
}).catch(handleError);
} catch (error: unknown) {
handleError(error);
}
};
React.useEffect(() => {
if (running === rState.Completed) setTimeout(() => {
setOpen(false);
setRunning(rState.Idle);
if (onCompleted) onCompleted();
} , 5000);
if (running === rState.Completed) setTimeout(() => {
setOpen(false);
setRunning(rState.Idle);
if (onCompleted) onCompleted();
}, 5000);
}, [running]);
React.useEffect(() => {
if (searchTerm && searchTerm !== "") {
context.msGraphClientFactory.getClient("3").then((client: MSGraphClientV3) => {
client.api('/me/people').search(searchTerm).filter("personType/subclass eq 'OrganizationUser'").get((error: GraphError, response: { value: MicrosoftGraph.Person[] }) => {
if (error) throw error.message;
else setSearchResults(response.value);
if (error) throw error.message;
else setSearchResults(response.value);
}).catch(handleError);
}).catch(handleError);
}).catch(handleError);
} else setSearchResults(null)
}, [searchTerm]);
const inputId = useId('input-with-placeholder');
return (
return (<>
<Button appearance='primary' icon={<PersonAddRegular />} onClick={() => setOpen(true)}>{strings.Add}</Button>
<Dialog open={open} onOpenChange={(event, data) => setOpen(data.open)}>
<DialogTrigger>
<Button appearance='primary' icon={<PersonAddRegular />}>{strings.Add}</Button>
</DialogTrigger>
<DialogSurface aria-label="label">
<DialogTitle>{Mode === AddUserMode.Owner ? strings.AddDialogTitleOwner : strings.AddDialogTitle}{Group.displayName}</DialogTitle>
<DialogSurface>
<DialogBody>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error}</Alert>}
{running === rState.Error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error}</Alert>}
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Added} {Group.displayName}</Alert>}
{users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
{running !== rState.Completed && <Checkbox disabled={running === rState.Running} defaultChecked onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id)) } />}
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}
</div>}
{(running === rState.Idle || running === rState.Error) && <>
<Divider />
<Label htmlFor={inputId}>{strings.Search}</Label>
<Input placeholder={strings.SearchPlaceholder} onChange={(e, d) => setSearchTerm(d.value)} id={inputId} />
{searchResults && searchResults.map(u => <div key={u.id} className={styles.stackHoz}>
<Checkbox onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id)) } />
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}
</>}
{running === rState.Running && <Spinner labelPosition='below' label={strings.Adding} /> }
</div>
<DialogTitle>{Mode === AddUserMode.Owner ? strings.AddDialogTitleOwner : strings.AddDialogTitle}{Group.displayName}</DialogTitle>
<DialogContent>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error}</Alert>}
{running === rState.Error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error}</Alert>}
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Added} {Group.displayName}</Alert>}
{users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
{running !== rState.Completed && <Checkbox disabled={running === rState.Running} defaultChecked onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id))} />}
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}
</div>}
{(running === rState.Idle || running === rState.Error) && <>
<Divider />
<Label htmlFor={inputId}>{strings.Search}</Label>
<Input placeholder={strings.SearchPlaceholder} onChange={(e, d) => setSearchTerm(d.value)} id={inputId} />
{searchResults && searchResults.map(u => <div key={u.id} className={styles.stackHoz}>
<Checkbox onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id))} />
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}
</>}
{running === rState.Running && <Spinner labelPosition='below' label={strings.Adding} />}
</div>
</DialogContent>
<DialogActions>
<DialogTrigger>
<Button appearance="secondary" disabled={running === rState.Running}>{strings.Close}</Button>
</DialogTrigger>
{users.length > 0 && <Button appearance="primary" disabled={running === rState.Completed || running === rState.Running} onClick={add}>{strings.Add}</Button>}
</DialogActions>
</DialogBody>
<DialogActions>
<DialogTrigger>
<Button appearance="secondary" disabled={running === rState.Running}>{strings.Close}</Button>
</DialogTrigger>
{users.length > 0 && <Button appearance="primary" disabled={running === rState.Completed || running === rState.Running} onClick={add}>{strings.Add}</Button>}
</DialogActions>
</DialogSurface>
</Dialog>
)
</>)
}

View File

@ -2,15 +2,15 @@ import * as React from 'react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import styles from './GroupMembershipManager.module.scss';
import * as strings from 'GroupMembershipManagerWebPartStrings'
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Alert } from "@fluentui/react-components/unstable";
import { Button, Spinner } from "@fluentui/react-components";
import { Alert } from "@fluentui/react-components/unstable";
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Button, Spinner, DialogContent } from "@fluentui/react-components";
import { AddUserMode } from './AddUser';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { PersonDeleteRegular } from '@fluentui/react-icons';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import SPFxPeopleCard from './SPFxPeopleCard';
import { BatchRequestStep, BatchRequestContent, BatchRequestBody, BatchResponseContent } from '@microsoft/microsoft-graph-client';
import { BatchRequestStep, BatchRequestContent, BatchRequestBody, BatchResponseContent } from '@microsoft/microsoft-graph-clientv1';
enum rState { Idle, Running, Error, Completed }
@ -41,7 +41,7 @@ export default function RemoveUser({ Group, Users, Mode, context, onCompleted }:
const userRequestSteps: BatchRequestStep[] = Users.map((v, i) => ({
id: i.toString(),
request: new Request(`/groups/${Group.id}/${Mode === AddUserMode.Member ? 'members' : 'owners'}/${v.id}/$ref`, {
method: "DELETE"
method: "DELETE"
})
}));
@ -74,33 +74,34 @@ export default function RemoveUser({ Group, Users, Mode, context, onCompleted }:
}, [running]);
return (
return (<>
<Button appearance='primary' icon={<PersonDeleteRegular />} disabled={Users.length === 0} onClick={() => setOpen(true)}>{strings.Remove}</Button>
<Dialog open={open} onOpenChange={(event, data) => setOpen(data.open)}>
<DialogTrigger>
<Button appearance='primary' icon={<PersonDeleteRegular />} disabled={Users.length === 0}>{strings.Remove}</Button>
</DialogTrigger>
<DialogSurface aria-label="label">
<DialogTitle>{Mode === AddUserMode.Owner ? strings.RemoveDialogTitleOwner : strings.RemoveDialogTitle}{Group.displayName}</DialogTitle>
<DialogBody>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error.split('||').map((v, i) => (<div key={i}>{v}</div>))}</Alert>}
{running === rState.Error && _error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error.split('||').map((v, i) => (<div key={i}>{v}</div>))}</Alert>}
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Removed} {Group.displayName}</Alert>}
{Users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{Users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.mail} />
</div>)}
</div>}
{running === rState.Running && <Spinner labelPosition='below' label={strings.Removing} />}
</div>
<DialogTitle>{Mode === AddUserMode.Owner ? strings.RemoveDialogTitleOwner : strings.RemoveDialogTitle}{Group.displayName}</DialogTitle>
<DialogContent>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error.split('||').map((v, i) => (<div key={i}>{v}</div>))}</Alert>}
{running === rState.Error && _error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error.split('||').map((v, i) => (<div key={i}>{v}</div>))}</Alert>}
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Removed} {Group.displayName}</Alert>}
{Users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{Users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.mail} />
</div>)}
</div>}
{running === rState.Running && <Spinner labelPosition='below' label={strings.Removing} />}
</div>
</DialogContent>
<DialogActions>
<DialogTrigger>
<Button appearance="secondary" disabled={running === rState.Running}>{strings.Close}</Button>
</DialogTrigger>
<Button appearance="primary" disabled={running === rState.Completed || running === rState.Running} onClick={remove}>{strings.Remove}</Button>
</DialogActions>
</DialogBody>
<DialogActions>
<DialogTrigger>
<Button appearance="secondary" disabled={running === rState.Running}>{strings.Close}</Button>
</DialogTrigger>
<Button appearance="primary" disabled={running === rState.Completed || running === rState.Running} onClick={remove}>{strings.Remove}</Button>
</DialogActions>
</DialogSurface>
</Dialog>
)
</>)
}

View File

@ -1,4 +1,4 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
@import '~@fluentui/react/dist/sass/References.scss';
.groupMembershipManager {
overflow: hidden;

View File

@ -4,16 +4,14 @@ import { IGroupMembershipManagerProps } from './IGroupMembershipManagerProps';
import * as strings from 'GroupMembershipManagerWebPartStrings';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { Checkbox, CheckboxOnChangeData, Spinner, Subtitle1, Textarea, Tooltip } from '@fluentui/react-components';
import { Spinner, Subtitle1, Textarea, Tooltip } from '@fluentui/react-components';
import { Dropdown, Option, Toolbar } from "@fluentui/react-components/unstable";
import { TableBody, TableCell, TableRow, Table } from '@fluentui/react-table';
import AddUser, { AddUserMode } from './AddUser';
import RemoveUser from './DeleteUser';
import { GraphError } from '@microsoft/microsoft-graph-client';
import SPFxPeopleCard from './SPFxPeopleCard';
import { PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { PeopleTeam24Regular, MailLink24Regular,ShieldLock24Regular, Eye24Regular, EyeOff24Regular } from '@fluentui/react-icons'
import { GraphError } from '@microsoft/microsoft-graph-clientv1';
import { PeopleTeam24Regular, MailLink24Regular, ShieldLock24Regular, Eye24Regular, EyeOff24Regular } from '@fluentui/react-icons'
import TableGrid from './Table';
type OnSelectData = {
optionValue: string;
selectedOptions: string[];
@ -34,7 +32,7 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
React.useEffect(() => {
context.msGraphClientFactory.getClient("3").then((client: MSGraphClientV3) => {
client.api('/me/ownedObjects').select("id,displayName,groupTypes,visibility,mailEnabled,resourceProvisioningOptions,securityEnabled,description").get((error, response: { value: MicrosoftGraph.Group[] }) => {
client.api('/me/ownedObjects').select("id,displayName,groupTypes,visibility,mailEnabled,resourceProvisioningOptions,securityEnabled,description").get((error: undefined, response: { value: MicrosoftGraph.Group[] }) => {
if (error) throw error;
setGroups(response.value.filter(g => g.groupTypes).sort((a, b) => a.displayName.localeCompare(b.displayName)));
}).catch(console.error);
@ -61,33 +59,25 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
if (group) loadGroup().catch(console.error);
}, [group]);
const checkUser = (data: CheckboxOnChangeData, user: MicrosoftGraph.User): void => {
setToRemove(data.checked ? toRemove.concat([user]) : toRemove.filter(u => u.id !== user.id));
};
const checkOwner = (data: CheckboxOnChangeData, user: MicrosoftGraph.User): void => {
setRemoveOwner(data.checked ? removeOwner.concat([user]) : removeOwner.filter(u => u.id !== user.id));
};
return (
<section className={`${styles.groupMembershipManager} ${hasTeamsContext ? styles.teams : ''}`}>
{!groups && <Spinner labelPosition='below' label={strings.LoadingGroups} />}
{groups && <section>
<Subtitle1 block>{strings.PickGroup}</Subtitle1>
<div className={styles.stackHoz} style={{ alignItems: 'center'}}>
<Dropdown placeholder={strings.PickGroup} size="large" onSelect={(e, d?: OnSelectData) => setGroup(d ? d.optionValue : null)}>
{groups.map(group => <Option id={group.id} key={group.id}>{group.displayName}</Option>)}
<div className={styles.stackHoz} style={{ alignItems: 'center' }}>
<Dropdown placeholder={strings.PickGroup} size="large" onOptionSelect={(e, d?: OnSelectData) => { setGroup(d?.optionValue); }}>
{groups.map(group => <Option key={group.mailNickname}>{group.displayName}</Option>)}
</Dropdown>
{group && groups.filter(g => g.displayName === group)[0].resourceProvisioningOptions?.filter(g => g === "Team").length === 1 &&
<Tooltip relationship='label' content="Team"><span><PeopleTeam24Regular /></span></Tooltip>
<Tooltip relationship='label' content="Team"><span><PeopleTeam24Regular /></span></Tooltip>
}
{group && groups.filter(g => g.displayName === group)[0].mailEnabled &&
{group && groups.filter(g => g.displayName === group)[0].mailEnabled &&
<Tooltip relationship='label' content="Mail Enabled"><span><MailLink24Regular /></span></Tooltip>
}
{group && groups.filter(g => g.displayName === group)[0].securityEnabled &&
{group && groups.filter(g => g.displayName === group)[0].securityEnabled &&
<Tooltip relationship='label' content="Security Enabled"><span><ShieldLock24Regular /></span></Tooltip>
}
{group && groups.filter(g => g.displayName === group)[0].visibility &&
{group && groups.filter(g => g.displayName === group)[0].visibility &&
<Tooltip relationship='label' content={groups.filter(g => g.displayName === group)[0].visibility}><span>
{groups.filter(g => g.displayName === group)[0].visibility === 'Public' && <Eye24Regular />}
{groups.filter(g => g.displayName === group)[0].visibility === 'Private' && <EyeOff24Regular />}
@ -96,34 +86,22 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
</div>
</section>}
{(!members || !owners) && groups && group && <Spinner labelPosition='below' label={strings.LoadingMembers} />}
{groups && group && <div className={styles.stack} style={{marginTop: 10 }}>
{groups && group && <div className={styles.stack} style={{ marginTop: 10 }}>
<Textarea value={groups.filter(g => g.displayName === group)[0].description} resize='vertical' readOnly />
<div className={`${styles.stackHoz} ${styles.spaceBetween}`} style={{marginTop: 10 }}>
<div style={{width: '49%'}}>
<div className={`${styles.stackHoz} ${styles.spaceBetween}`} style={{ marginTop: 10 }}>
<div style={{ width: '49%' }}>
<Subtitle1>{strings.Members}</Subtitle1>
{!members && <Spinner labelPosition='below' label={strings.LoadingMembers} />}
{members && <>
{members && <>
{//don't display the toolbar if the group is dynamic
!(groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0) && <Toolbar>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
<RemoveUser Users={toRemove} context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
</Toolbar>}
<Table>
<TableBody>
{members.map(user => (
<TableRow key={user.id}>
<TableCell>
{!(groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0) &&
<Checkbox onChange={(ev, data) => checkUser(data, user)} />}
<SPFxPeopleCard primaryText={user.displayName} serviceScope={context.serviceScope} email={user.userPrincipalName} size={PersonaSize.size24} secondaryText={user.mail} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
<RemoveUser Users={toRemove} context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
</Toolbar>}
<TableGrid Users={members} ServiceScope={context.serviceScope} onSelectionChange={setToRemove} />
</>}
</div>
<div style={{width: '49%'}}>
<div style={{ width: '49%' }}>
<Subtitle1>{strings.Owners}</Subtitle1>
{!owners && <Spinner labelPosition='below' label={strings.LoadingOwners} />}
{owners && <>
@ -131,19 +109,7 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Owner} onCompleted={loadGroup} />
<RemoveUser Users={removeOwner} context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Owner} onCompleted={loadGroup} />
</Toolbar>
<Table>
<TableBody>
{owners.map(user => (
<TableRow key={user.id}>
<TableCell>
{!(groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0) &&
<Checkbox onChange={(ev, data) => checkOwner(data, user)} />}
<SPFxPeopleCard primaryText={user.displayName} serviceScope={context.serviceScope} email={user.userPrincipalName} size={PersonaSize.size24} secondaryText={user.mail} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TableGrid Users={owners} ServiceScope={context.serviceScope} onSelectionChange={setRemoveOwner} ReadOnly={groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0} />
</>}
</div>
</div>

View File

@ -0,0 +1,106 @@
import { TableColumnDefinition, createTableColumn, TableRowId, useTableFeatures, useTableSelection, TableHeader, TableRow, Table, TableSelectionCell, TableHeaderCell, TableBody, TableCell, TableCellLayout } from "@fluentui/react-components/unstable";
import * as React from 'react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import SPFxPeopleCard from "./SPFxPeopleCard";
import { ServiceScope } from "@microsoft/sp-core-library";
import { PersonaSize } from "office-ui-fabric-react/lib/Persona";
export interface TableGridProps {
Users: MicrosoftGraph.User[];
ServiceScope: ServiceScope;
ReadOnly?: boolean;
onSelectionChange?: (SelectedUsers: MicrosoftGraph.User[]) => void;
}
export default function TableGrid(props: TableGridProps): React.ReactElement<TableGridProps> {
const columns: TableColumnDefinition<MicrosoftGraph.User>[] = React.useMemo(
() => [
createTableColumn<MicrosoftGraph.User>({
columnId: 'username',
})
],
[],
);
const [selectedRows, setSelectedRows] = React.useState(() => new Set<TableRowId>([]));
const {
getRows,
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
} = useTableFeatures(
{
columns,
items: props.Users,
},
[
useTableSelection({
selectionMode: 'multiselect',
selectedItems: selectedRows,
onSelectionChange: (e, data) => {
if (props.ReadOnly) return;
if (props.onSelectionChange) props.onSelectionChange(props.Users.filter((v, i) => data.selectedItems.has(i)));
setSelectedRows(data.selectedItems);
}
}),
],
);
const rows = getRows(row => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => { if (!props.ReadOnly) toggleRow(e, row.rowId) },
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === ' ') {
e.preventDefault();
if (!props.ReadOnly) toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ('brand' as const) : ('none' as const),
};
});
const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
toggleAllRows(e);
e.preventDefault();
}
},
[toggleAllRows],
);
return (<Table aria-label="Table with multiselect">
<TableHeader>
<TableRow>
{!props.ReadOnly && <TableSelectionCell
checked={allRowsSelected ? true : someRowsSelected ? 'mixed' : false}
onClick={toggleAllRows}
onKeyDown={toggleAllKeydown}
checkboxIndicator={{ 'aria-label': 'Select all rows ' }}
/>}
<TableHeaderCell>User</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(({ item, selected, onClick, onKeyDown, appearance }) => (
<TableRow
key={item.id}
onClick={onClick}
onKeyDown={onKeyDown}
aria-selected={selected}
appearance={appearance}
>
{!props.ReadOnly && <TableSelectionCell checked={selected} checkboxIndicator={{ 'aria-label': 'Select row' }} />}
<TableCell>
<TableCellLayout>
<SPFxPeopleCard primaryText={item.displayName} serviceScope={props.ServiceScope} email={item.userPrincipalName} size={PersonaSize.size24} secondaryText={item.mail} />
</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>);
}

View File

@ -0,0 +1,63 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.14/MicrosoftTeams.schema.json",
"manifestVersion": "1.14",
"packageName": "react-group-membership-manager-client-side-solution",
"id": "698a4f5b-686f-475d-81fb-9bcf98850b96",
"version": "1.1.0",
"developer": {
"name": "Nick Brown",
"websiteUrl": "https://nbdev.uk",
"privacyUrl": "https://nbdev.uk",
"termsOfUseUrl": "https://nbdev.uk"
},
"name": {
"short": "Group Membership Manager",
"full": "React Group Membership Manager"
},
"description": {
"short": "Group Membership Manager",
"full": "React Group Membership Manager using Fluent UI 9"
},
"icons": {
"outline": "698a4f5b-686f-475d-81fb-9bcf98850b96_outline.png",
"color": "698a4f5b-686f-475d-81fb-9bcf98850b96_color.png"
},
"accentColor": "#004578",
"staticTabs": [
{
"contentUrl": "https://{teamSiteDomain}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest=/_layouts/15/teamshostedapp.aspx%3Fteams%26personal%26componentId=698a4f5b-686f-475d-81fb-9bcf98850b96%26forceLocale={locale}",
"name": "Group Membership Manager",
"scopes": ["personal"],
"entityId": "100001"
}
],
"configurableTabs": [
{
"configurationUrl": "https://{teamSiteDomain}{teamSitePath}/_layouts/15/TeamsLogon.aspx?SPFX=true&dest={teamSitePath}/_layouts/15/teamshostedapp.aspx%3FopenPropertyPane=true%26teams%26componentId=698a4f5b-686f-475d-81fb-9bcf98850b96%26forceLocale={locale}",
"canUpdateConfiguration": false,
"scopes": [
"team",
"groupchat"
],
"context": [
"channelTab",
"privateChatTab",
"meetingSidePanel",
"meetingDetailsTab",
"meetingChatTab"
]
}
],
"permissions": [
"identity"
],
"validDomains": [
"*.login.microsoftonline.com",
"*.sharepoint.com",
"resourceseng.blob.core.windows.net"
],
"webApplicationInfo": {
"resource": "https://{teamSiteDomain}",
"id": "00000003-0000-0ff1-ce00-000000000000"
}
}

View File

@ -13,9 +13,7 @@
"outDir": "lib",
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"noImplicitAny": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"

View File

@ -0,0 +1,29 @@
yo @microsoft/sharepoint
spfx-fast-serve
npm install react react-dom leaflet
npm i @spfxappdev/utility
npm i
npm install react-leaflet
npm install -D @types/leaflet
npm run serve
gulp serve
npm run serve
npm install -D @types/leaflet babel-loader @babel/core @babel/preset-env @babel/plugin-proposal-nullish-coalescing-operator
npm run serve
exit
npm run serve
npm install @pnp/spfx-controls-react --save --save-exact
npm run serve
exit
npm i react-leaflet-markercluster
npm run serve
npm install @pnp/spfx-property-controls --save --save-exact
npm run serve
gulp clean; gulp build; gulp bundle --ship; gulp package-solution --ship;
npm run serve
exit
npm run serve
gulp clean; gulp build; gulp bundle --ship; gulp package-solution --ship;
npm run serve
gulp clean; gulp build; gulp bundle --ship; gulp package-solution --ship;
exit

View File

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
.npm
.cache
.config
.rushstack
# Build generated files
dist
lib
release
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts
*.scss.d.ts

View File

@ -0,0 +1,16 @@
!dist
config
gulpfile.js
release
src
temp
tsconfig.json
tslint.json
*.log
.yo-rc.json
.vscode

View File

@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Hosted workbench",
"type": "pwa-chrome",
"request": "launch",
"url": "https://enter-your-SharePoint-site/_layouts/workbench.aspx",
"webRoot": "${workspaceRoot}",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///.././src/*": "${webRoot}/src/*",
"webpack:///../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../src/*": "${webRoot}/src/*",
"webpack:///../../../../../src/*": "${webRoot}/src/*"
},
"runtimeArgs": [
"--remote-debugging-port=9222",
"-incognito"
]
}
]
}

View File

@ -0,0 +1,13 @@
// Place your settings in this file to overwrite default and user settings.
{
// Configure glob patterns for excluding files and folders in the file explorer.
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/bower_components": true,
"**/coverage": true,
"**/lib-amd": true,
"src/**/*.scss.ts": true
},
"typescript.tsdk": ".\\node_modules\\typescript\\lib"
}

View File

@ -0,0 +1,16 @@
{
"@microsoft/generator-sharepoint": {
"plusBeta": false,
"isCreatingSolution": true,
"version": "1.14.0",
"libraryName": "spfxappdev-webparts-map",
"libraryId": "cc048abe-6531-4295-ab7a-12a1c95de606",
"environment": "spo",
"packageManager": "npm",
"solutionName": "spfxappdev.webparts.map",
"solutionShortDescription": "spfxappdev.webparts.map description",
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,89 @@
# Interactive Map
## Summary
This web parts displays a (world) map. An editor can set custom markers directly in the map. Each marker can configured individually. It is possible to determine the color of the pin, the icon in the pin or what should happen when the pin is clicked. It is even possible to change the tile layer in the web part properties.
![EditMode](assets/WPPreview.png)
### Create new marker
![Create new Marker](assets/CreateNewMarker.png)
### Preview
![Interactive Map web part preview](assets/MapWPOverview.gif)
## Compatibility
![SPFx 1.14](https://img.shields.io/badge/SPFx-1.14-green.svg)
![Node.js v14 | v12](https://img.shields.io/badge/Node.js-v14%20%7C%20v12-green.svg)
![Compatible with SharePoint Online](https://img.shields.io/badge/SharePoint%20Online-Compatible-green.svg)
![Does not work with SharePoint 2019](https://img.shields.io/badge/SharePoint%20Server%202019-Incompatible-red.svg "SharePoint Server 2019 requires SPFx 1.4.1 or lower")
![Does not work with SharePoint 2016 (Feature Pack 2)](https://img.shields.io/badge/SharePoint%20Server%202016%20(Feature%20Pack%202)-Incompatible-red.svg "SharePoint Server 2016 Feature Pack 2 requires SPFx 1.1")
![Local Workbench Unsupported](https://img.shields.io/badge/Local%20Workbench-Unsupported-red.svg "Local workbench is no longer available as of SPFx 1.13 and above")
![Hosted Workbench Compatible](https://img.shields.io/badge/Hosted%20Workbench-Compatible-green.svg)
![Compatible with Remote Containers](https://img.shields.io/badge/Remote%20Containers-Compatible-green.svg)
## Applies to
* [SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
* [Microsoft 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/o365devprogram)
## Solution
Solution|Author(s)
--------|---------
react-interactive-map | [Sergej Schwabauer](https://github.com/SPFxAppDev) ([@spfxappdev](https://twitter.com/spfxappdev))
## Version history
Version|Date|Comments
-------|----|--------
1.0|January 19, 2023|Initial release
## Minimal path to awesome
* Clone this repository (or [download this solution as a .ZIP file](https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-interactive-map) then unzip it)
* From your command line, change your current directory to the directory containing this sample (`react-interactive-map`, located under `samples`)
* in the command line run:
* `npm install`
* `gulp serve`
> This sample can also be opened with [VS Code Remote Development](https://code.visualstudio.com/docs/remote/remote-overview). Visit <https://aka.ms/spfx-devcontainer> for further instructions.
## Features
This Web Part illustrates the following concepts on top of the SharePoint Framework:
* [Fluent UI React Controls](https://developer.microsoft.com/en-us/fluentui#/controls/web)
* [OpenStreetMap](https://www.openstreetmap.org/)
* [LeafletJS](https://leafletjs.com/) and the [react-leaflet](https://react-leaflet.js.org/) wrapper
* [Leaflet Plugin "Marker cluster"](https://github.com/Leaflet/Leaflet.markercluster) ant the [react-leaflet-markercluster](https://www.npmjs.com/package/react-leaflet-markercluster) wrapper
## Help
We do not support samples, but this community is always willing to help, and we want to improve these samples. We use GitHub to track issues, which makes it easy for community members to volunteer their time and help resolve issues.
If you're having issues building the solution, please run [spfx doctor](https://pnp.github.io/cli-microsoft365/cmd/spfx/spfx-doctor/) from within the solution folder to diagnose incompatibility issues with your environment.
You can try looking at [issues related to this sample](https://github.com/pnp/sp-dev-fx-webparts/issues?q=label%3A%22sample%3A%20react-interactive-map%22) to see if anybody else is having the same issues.
You can also try looking at [discussions related to this sample](https://github.com/pnp/sp-dev-fx-webparts/discussions?discussions_q=react-interactive-map) and see what the community is saying.
If you encounter any issues using this sample, [create a new issue](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected%2Csample%3A%20react-interactive-map&template=bug-report.yml&sample=react-interactive-map&authors=@SPFxAppDev&title=react-interactive-map%20-%20).
For questions regarding this sample, [create a new question](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aquestion%2Csample%3A%20react-interactive-map&template=question.yml&sample=react-interactive-map&authors=@SPFxAppDev&title=react-interactive-map%20-%20).
Finally, if you have an idea for improvement, [make a suggestion](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aenhancement%2Csample%3A%20react-interactive-map&template=suggestion.yml&sample=react-interactive-map&authors=@SPFxAppDev&title=react-interactive-map%20-%20).
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
<img src="https://pnptelemetry.azurewebsites.net/sp-dev-fx-webparts/samples/react-interactive-map" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -0,0 +1,50 @@
[
{
"name": "pnp-sp-dev-spfx-web-parts-react-interactive-map",
"source": "pnp",
"title": "Interactive Map",
"shortDescription": "This web parts displays a (world) map. An editor can set custom markers directly in the map. Each marker can configured individually.",
"url": "https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-interactive-map",
"downloadUrl": "https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-interactive-map",
"longDescription": [
"This web parts displays a (world) map. An editor can set custom markers directly in the map. Each marker can configured individually."
],
"creationDateTime": "2023-01-31",
"updateDateTime": "2023-01-31",
"products": [
"SharePoint"
],
"metadata": [
{
"key": "CLIENT-SIDE-DEV",
"value": "React"
},
{
"key": "SPFX-VERSION",
"value": "1.16.1"
}
],
"thumbnails": [
{
"type": "image",
"order": 100,
"url": "https://raw.githubusercontent.com/pnp/sp-dev-fx-webparts/main/samples/react-interactive-map/assets/WPPreview.png",
"alt": "Web Part Preview"
}
],
"authors": [
{
"gitHubAccount": "SPFxAppDev",
"pictureUrl": "https://github.com/SPFxAppDev.png",
"name": "Sergej Schwabauer"
}
],
"references": [
{
"name": "Build your first SharePoint client-side web part",
"description": "Client-side web parts are client-side components that run in the context of a SharePoint page. Client-side web parts can be deployed to SharePoint environments that support the SharePoint Framework. You can also use modern JavaScript web frameworks, tools, and libraries to build them.",
"url": "https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/build-a-hello-world-web-part"
}
]
}
]

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"map-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/map/MapWebPart.js",
"manifest": "./src/webparts/map/MapWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"MapWebPartStrings": "lib/webparts/map/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./release/assets/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "spfxappdev-webparts-map",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,40 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "SPFx app dev - interactive map webpart",
"id": "cc048abe-6531-4295-ab7a-12a1c95de606",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"developer": {
"name": "Sergej Schwabauer",
"websiteUrl": "https://spfx-app.dev/",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": "Undefined-1.14.0"
},
"metadata": {
"shortDescription": {
"default": "Includes an interactive map module with which you can create markers"
},
"longDescription": {
"default": "Includes an interactive map module with which you can create markers"
},
"screenshotPaths": [],
"videoUrl": "",
"categories": []
},
"features": [
{
"title": "spfxappdev-webparts-map Feature",
"description": "The feature that activates elements of the spfxappdev-webparts-map solution.",
"id": "e90a5f60-6586-4a90-925e-70a78df55b29",
"version": "1.0.0.0"
}
]
},
"paths": {
"zippedPackage": "solution/spfxappdev-webparts-map.sppkg"
}
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"ipAddress": "0.0.0.0",
"initialPage": "https://sscwebdev.sharepoint.com/sites/showroom/_layouts/workbench.aspx"
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/s-KaiNet/spfx-fast-serve/master/schema/config.latest.schema.json",
"cli": {
"isLibraryComponent": false
}
}

View File

@ -0,0 +1,47 @@
/*
* User webpack settings file. You can add your own settings here.
* Changes from this file will be merged into the base webpack configuration file.
* This file will not be overwritten by the subsequent spfx-fast-serve calls.
*/
// you can add your project related webpack configuration here, it will be merged using webpack-merge module
// i.e. plugins: [new webpack.Plugin()]
const path = require("path");
const webpackConfig = {
resolve: {
alias: {
"@webparts": path.resolve(__dirname, "..", "src/webparts"),
"@src": path.resolve(__dirname, "..", "src"),
}
}
}
// for even more fine-grained control, you can apply custom webpack settings using below function
const transformConfig = function (initialWebpackConfig) {
// transform the initial webpack config here, i.e.
// initialWebpackConfig.plugins.push(new webpack.Plugin()); etc.
initialWebpackConfig.module.rules.push(
{
test: /node_modules[\/\\]@?react-leaflet[\/\\].*.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: "defaults" }]
],
plugins: ['@babel/plugin-proposal-nullish-coalescing-operator']
}
}
}
);
return initialWebpackConfig;
}
module.exports = {
webpackConfig,
transformConfig
}

View File

@ -0,0 +1,61 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.addSuppression(/Warning - \[sass\] The local CSS class/gi);
var getTasks = build.rig.getTasks;
build.rig.getTasks = function () {
var result = getTasks.call(build.rig);
result.set('serve', result.get('serve-deprecated'));
return result;
};
/* fast-serve */
const { addFastServe } = require("spfx-fast-serve-helpers");
addFastServe(build);
/* end of fast-serve */
/* CUSTOM ALIAS */
const path = require('path');
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
if(!generatedConfiguration.resolve.alias){
generatedConfiguration.resolve.alias = {};
}
// webparts folder
generatedConfiguration.resolve.alias['@webparts'] = path.resolve( __dirname, 'lib/webparts')
//root src folder
generatedConfiguration.resolve.alias['@src'] = path.resolve( __dirname, 'lib')
//Nullish Operator
generatedConfiguration.module.rules.push(
{
test: /node_modules[\/\\]@?react-leaflet[\/\\].*.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { targets: "defaults" }]
],
plugins: ['@babel/plugin-proposal-nullish-coalescing-operator']
}
}
}
);
return generatedConfiguration;
}
});
/* CUSTOM ALIAS END */
build.initialize(require('gulp'));

View File

@ -0,0 +1 @@
<svg width="50px" height="50px" viewBox="0 0 50 50" version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" overflow="inherit"><path d="M25.015 2.4c-7.8 0-14.121 6.204-14.121 13.854 0 7.652 14.121 32.746 14.121 32.746s14.122-25.094 14.122-32.746c0-7.65-6.325-13.854-14.122-13.854z"/></svg>

After

Width:  |  Height:  |  Size: 301 B

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"name": "spfxappdev-webparts-map",
"version": "1.0.0",
"private": true,
"main": "lib/index.js",
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test",
"serve": "gulp bundle --custom-serve --max_old_space_size=4096 && fast-serve"
},
"dependencies": {
"@microsoft/sp-core-library": "1.14.0",
"@microsoft/sp-lodash-subset": "1.14.0",
"@microsoft/sp-office-ui-fabric-core": "1.14.0",
"@microsoft/sp-property-pane": "1.14.0",
"@microsoft/sp-webpart-base": "1.14.0",
"@pnp/spfx-controls-react": "3.6.0",
"@pnp/spfx-property-controls": "3.5.0",
"@spfxappdev/utility": "^1.1.0",
"leaflet": "^1.7.1",
"office-ui-fabric-react": "7.174.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-leaflet": "^3.2.5",
"react-leaflet-markercluster": "^3.0.0-rc1"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@babel/preset-env": "^7.16.11",
"@microsoft/rush-stack-compiler-3.9": "0.4.47",
"@microsoft/sp-build-web": "1.14.0",
"@microsoft/sp-module-interfaces": "1.14.0",
"@microsoft/sp-tslint-rules": "1.14.0",
"@types/leaflet": "^1.7.9",
"@types/react": "16.9.51",
"@types/react-dom": "16.9.8",
"@types/webpack-env": "1.13.1",
"ajv": "~5.2.2",
"babel-loader": "^8.2.3",
"gulp": "~4.0.2",
"spfx-fast-serve-helpers": "~1.14.0"
}
}

View File

@ -0,0 +1,5 @@
.autocomplete {
display: block;
}

View File

@ -0,0 +1,207 @@
import * as React from 'react';
import styles from './Autocomplete.module.scss';
import { TextField, ITextFieldProps, Callout, ICalloutProps, DirectionalHint, ITextField, TextFieldBase } from 'office-ui-fabric-react';
import { isNullOrEmpty, cssClasses, getDeepOrDefault, isFunction } from '@spfxappdev/utility';
export interface IAutocompleteProps extends Omit<ITextFieldProps, "componentRef"> {
showSuggestionsOnFocus?: boolean;
minValueLength?: number;
onLoadSuggestions?(newValue: string): void;
onRenderSuggestions?(inputValue: string): JSX.Element;
textFieldRef?(fluentUITextField: ITextField, autocompleteComponent: Autocomplete, htmlInput?: HTMLInputElement);
onUpdated?(newValue: string);
calloutProps?: Omit<ICalloutProps, "hidden" | "target" | "preventDismissOnScroll" | "directionalHint" | "directionalHintFixed" | "isBeakVisible">;
}
interface IAutocompleteState {
currentValue: string;
isFlyoutVisible: boolean;
}
export class Autocomplete extends React.Component<IAutocompleteProps, IAutocompleteState> {
public state: IAutocompleteState = {
currentValue: isNullOrEmpty(this.props.defaultValue) ? "" : this.props.defaultValue,
isFlyoutVisible: false,
};
public static defaultProps: IAutocompleteProps = {
showSuggestionsOnFocus: false,
minValueLength: 3,
calloutProps: {
gapSpace: 0
}
};
private textFieldReference: ITextField = null;
private textFieldDomElement: HTMLInputElement = null;
private userIsTyping: boolean = false;
private lastValue: string = "";
private onUpdateValueText: string = "";
public render(): React.ReactElement<IAutocompleteProps> {
return (<>
<TextField {...this.props}
autoComplete={"off"}
className={cssClasses(styles.autocomplete, this.props.className)}
componentRef={(input: ITextField) => {
this.textFieldReference = input;
this.textFieldDomElement = getDeepOrDefault<HTMLInputElement>(input, "_textElement.current", null);
if(isFunction(this.props.textFieldRef)) {
this.props.textFieldRef(input, this, this.textFieldDomElement);
}
}}
onFocus={(ev: any) => {
if(this.props.showSuggestionsOnFocus) {
this.handleSuggestionListVisibility();
}
if(isFunction(this.props.onFocus)) {
this.props.onFocus(ev);
}
}}
onBlur={(ev: any) => {
this.onTextFieldBlur();
if(isFunction(this.props.onBlur)) {
this.props.onBlur(ev);
}
}}
onChange={(ev: any, newValue: string) => {
this.onValueChanged(ev, newValue);
}}
defaultValue={this.state.currentValue}
/>
{this.renderSuggesstionsFlyout()}
</>);
}
public updateValue(newValue: string): void {
this.onUpdateValueText = newValue;
this.setState({
currentValue: newValue
}, () => {
(this.textFieldReference as TextFieldBase).setState({
uncontrolledValue: this.onUpdateValueText
});
if(isFunction(this.props.onUpdated)) {
this.props.onUpdated(newValue);
}
});
}
private renderSuggesstionsFlyout(): JSX.Element {
let minWidth: number = getDeepOrDefault<number>(this.props, "calloutProps.calloutMinWidth", -1);
if(minWidth <= 0) {
minWidth = getDeepOrDefault<number>(this, "textFieldDomElement.clientWidth", -1);
}
if(minWidth > 0) {
this.props.calloutProps.calloutMinWidth = minWidth;
}
return (<Callout
{...this.props.calloutProps}
hidden={!this.state.isFlyoutVisible}
directionalHintFixed={true}
isBeakVisible={false}
target={this.textFieldDomElement}
onDismiss={(ev?: any) => {
this.hideSuggesstionsFlyout();
if(isFunction(this.props.calloutProps.onDismiss)) {
this.props.calloutProps.onDismiss(ev);
}
}}
preventDismissOnScroll={true}
directionalHint={DirectionalHint.bottomCenter}>
{isFunction(this.props.onRenderSuggestions) && this.props.onRenderSuggestions(this.state.currentValue)}
</Callout>
);
}
private onValueChanged(ev: any, newValue: string): void {
this.userIsTyping = true;
this.state.currentValue = newValue;
this.setState({
currentValue: newValue
});
this.handleSuggestionListVisibility();
if(isFunction(this.props.onChange)) {
this.props.onChange(ev, newValue);
}
}
private onTextFieldBlur(): void {
this.userIsTyping = false;
window.setTimeout(() => {
this.hideSuggesstionsFlyout();
}, 150);
}
private handleSuggestionListVisibility(): void {
let val = this.state.currentValue;
if(isNullOrEmpty(val)) {
this.hideSuggesstionsFlyout();
return;
}
if(val.length < this.props.minValueLength) {
this.hideSuggesstionsFlyout();
return;
}
let valueWasChanged = false;
if(!val.Equals(this.lastValue)) {
this.userIsTyping = false;
valueWasChanged = true;
}
if(!valueWasChanged) {
this.showSuggesstionsFlyout();
return;
}
window.setTimeout(() => {
if(this.userIsTyping) {
return;
}
this.showSuggesstionsFlyout();
if(isFunction(this.props.onLoadSuggestions)) {
this.props.onLoadSuggestions(this.state.currentValue);
}
}, 150);
}
private hideSuggesstionsFlyout(): void {
this.setState({
isFlyoutVisible: false
});
}
private showSuggesstionsFlyout(): void {
this.setState({
isFlyoutVisible: true
});
}
}

View File

@ -0,0 +1,36 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.iconpicker {
display: block;
}
.suggesstion {
display: block;
&-item {
display: flex;
padding: 5px 20px;
border-bottom: solid 1px $ms-color-themeLighter;
border-top: solid 1px $ms-color-themeLighter;
align-items: center;
cursor: pointer;
&:first-child {
border-top-width: 0;
}
&:last-child {
border-bottom-width: 0;
}
i {
padding-right: 20px;
font-size: 2em;
}
span {
font-size: 1em;
}
}
}

View File

@ -0,0 +1,104 @@
import * as React from 'react';
import styles from './IconPicker.module.scss';
import { Icon, ITextField } from 'office-ui-fabric-react';
import { allIcons } from './availableIcons';
import { isNullOrEmpty, cssClasses, isFunction } from '@spfxappdev/utility';
import { Autocomplete, IAutocompleteProps } from '@src/components/autocomplete/Autocomplete';
export interface IIconPickerProps extends Omit<IAutocompleteProps, "onUpdated" | "onChange"> {
enableDialogPicker?: boolean;
dialogPickerIconName?: string;
onIconChanged?(iconName: string): void;
}
interface IIconPickerState {
currentValue: string;
}
export class IconPicker extends React.Component<IIconPickerProps, IIconPickerState> {
public state: IIconPickerState = {
currentValue: isNullOrEmpty(this.props.defaultValue) ? "" : this.props.defaultValue
};
public static defaultProps: IIconPickerProps = {
dialogPickerIconName: "GroupedList",
enableDialogPicker: true,
showSuggestionsOnFocus: false,
minValueLength: 0
};
private inputValueOnClick: string = "";
private textFieldReference: ITextField = null;
private textFieldDomElement: HTMLInputElement = null;
private autocompleteRef: Autocomplete = null;
public render(): React.ReactElement<IIconPickerProps> {
return (<>
<Autocomplete {...this.props}
textFieldRef={(fluentUITextField: ITextField, autocompleteComponent: Autocomplete, htmlInput: HTMLInputElement) => {
this.textFieldReference = fluentUITextField;
this.textFieldDomElement = htmlInput;
this.autocompleteRef = autocompleteComponent;
if(isFunction(this.props.textFieldRef)) {
this.props.textFieldRef(fluentUITextField, autocompleteComponent, this.textFieldDomElement);
}
}}
onChange={(ev: any, name: string) => {
if(isFunction(this.props.onIconChanged)) {
this.props.onIconChanged(name);
}
}}
onUpdated={(name: string) => {
if(isFunction(this.props.onIconChanged)) {
this.props.onIconChanged(name);
}
}}
className={cssClasses(styles.iconpicker)}
defaultValue={this.state.currentValue}
onLoadSuggestions={(newValue: string) => {
this.setState({
currentValue: newValue
});
}}
onRenderSuggestions={() => {
return this.renderSuggesstionsFlyout();
}}
iconProps={{
iconName: this.state.currentValue
}} />
</>);
}
private renderSuggesstionsFlyout(): JSX.Element {
return (
<div className={styles["suggesstion"]}>
{allIcons.Where(icon => icon.StartsWith(this.state.currentValue)).map((iconName: string): JSX.Element => {
return (<div
key={`Icon_${iconName}`}
onClick={() => {
this.inputValueOnClick = iconName;
this.setState({
currentValue: iconName
});
this.autocompleteRef.updateValue(iconName);
}}
className={styles["suggesstion-item"]}>
<Icon iconName={iconName} />
<span>{iconName}</span>
</div>);
})}
</div>
);
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
.inline-color-picker {
padding: 5px;
background: rgb(255, 255, 255);
border-radius: 1px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 1px;
display: inline-block;
border: 1px solid rgb(166, 166, 166);
cursor: pointer;
&-inner {
width: 36px;
height: 14px;
border-radius: 2px;
}
&.disabled {
background: darkgrey;
cursor: not-allowed;
}
}

View File

@ -0,0 +1,76 @@
import * as React from 'react';
import { ColorPicker, IColorPickerProps, getColorFromString, IColor, Callout, Label } from 'office-ui-fabric-react';
import styles from './InlineColorPicker.module.scss';
import { isset, isNullOrEmpty } from '@spfxappdev/utility';
export interface IInlineColorPickerProps extends IColorPickerProps {
label?: string;
isDisbaled?: boolean;
}
interface IInlineColorPickerState {
isPickerVisible: boolean;
}
export class InlineColorPicker extends React.Component<IInlineColorPickerProps, IInlineColorPickerState> {
public state: IInlineColorPickerState = {
isPickerVisible: false,
};
public static defaultProps: IInlineColorPickerProps = {
color: '#000000',
isDisbaled: false
};
private targetElement: HTMLDivElement = null;
public render(): React.ReactElement<IInlineColorPickerProps> {
let bc: IColor = null;
if(typeof this.props.color != "string") {
bc = this.props.color;
}
else {
bc = getColorFromString(this.props.color);
}
const customCss: React.CSSProperties = {
background: `rgba(${bc.r}, ${bc.g}, ${bc.b}, ${bc.a/100})`
};
return (
<>
{!isNullOrEmpty(this.props.label) &&
<Label>{this.props.label}</Label>
}
<div
className={styles['inline-color-picker'] + ` ${this.props.isDisbaled?styles['disabled']:''}`}
ref={(r) => {
if(isset(r)) {
this.targetElement = r;
}
}}
onClick={() => {
if(this.props.isDisbaled) {
return;
}
this.setState({ isPickerVisible: true });
}}>
<div className={styles['inline-color-picker-inner']} style={customCss}></div>
</div>
{this.state.isPickerVisible &&
<Callout target={this.targetElement} onDismiss={() => {
this.setState({ isPickerVisible: false });
}}>
<ColorPicker {...this.props} />
</Callout>
}
</>
);
}
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,52 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "3f860b48-1dc3-496d-bd28-b145672289cc",
"alias": "MapWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportsFullBleed": true,
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": {
"default": "Interactive Map",
"de-de": "Interaktive Karte"
},
"description": {
"default": "An interactive map with which you can create markers",
"de-de": "Eine interactive Karte mit der man Markierungen erstellen kann"
},
"officeFabricIconFontName": "MapPin",
"properties": {
"title": "",
"markerItems": [],
"markerCategories": [],
"center": [51.505, -0.09],
"startZoom": 13,
"maxZoom": 30,
"minZoom": 1,
"dragging": true,
"height": 400,
"scrollWheelZoom": true,
"plugins": {
"searchBox": false,
"markercluster": false,
"legend": false,
"zoomControl": true
},
"tileLayerUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"tileLayerAttribution": "&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
}
}]
}

View File

@ -0,0 +1,280 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { DisplayMode, Version } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle,
PropertyPaneSlider,
PropertyPaneButton
} from '@microsoft/sp-property-pane';
import { PropertyPaneWebPartInformation } from '@pnp/spfx-property-controls/lib/PropertyPaneWebPartInformation';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from 'MapWebPartStrings';
import Map from './components/Map';
import { IMapProps, IMarker, IMarkerCategory } from './components/IMapProps';
import ManageMarkerCategoriesDialog, { IManageMarkerCategoriesDialogProps } from './components/ManageMarkerCategoriesDialog';
import { isNullOrEmpty } from '@spfxappdev/utility';
import { Spinner, ISpinnerProps } from '@microsoft/office-ui-fabric-react-bundle';
export interface IMapPlugins {
searchBox: boolean;
markercluster: boolean;
legend: boolean;
zoomControl: boolean;
}
export interface IMapWebPartProps {
markerItems: IMarker[];
markerCategories: IMarkerCategory[];
title: string;
center: [number, number];
startZoom: number;
maxZoom: number;
minZoom: number;
height: number;
scrollWheelZoom: boolean;
dragging: boolean;
showPopUp: boolean;
plugins: IMapPlugins;
tileLayerUrl: string;
tileLayerAttribution: string;
}
export default class MapWebPart extends BaseClientSideWebPart<IMapWebPartProps> {
private _isDarkTheme: boolean = false;
protected onInit(): Promise<void> {
return super.onInit();
}
public render(): void {
const element: React.ReactElement<IMapProps> = React.createElement(
Map,
{
markerItems: this.properties.markerItems||[],
markerCategories: this.properties.markerCategories||[],
isEditMode: this.displayMode == DisplayMode.Edit,
zoom: this.properties.startZoom,
minZoom: this.properties.minZoom,
maxZoom: this.properties.maxZoom,
center: this.properties.center,
title: this.properties.title,
height: this.properties.height,
plugins: this.properties.plugins,
tileLayerUrl: isNullOrEmpty(this.properties.tileLayerUrl) ? "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" : this.properties.tileLayerUrl,
tileLayerAttribution: isNullOrEmpty(this.properties.tileLayerAttribution) ? "&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors" : this.properties.tileLayerAttribution,
dragging: this.properties.dragging,
scrollWheelZoom: this.properties.scrollWheelZoom,
showPopUp: this.properties.showPopUp,
onMarkerCollectionChanged: (markerItems: IMarker[]) => {
this.properties.markerItems = markerItems;
},
onMarkerCategoriesChanged: (markerCategories: IMarkerCategory[]) => {
this.onMarkerCategoriesChanged(markerCategories);
},
onStartViewSet: (zoomLevel: number, lat: number, lng: number) => {
this.properties.startZoom = zoomLevel;
this.properties.center = [lat, lng];
},
onTitleUpdate: (value: string) => {
this.properties.title = value;
}
}
);
ReactDom.render(element, this.domElement);
}
protected onDisplayModeChanged(oldDisplayMode: DisplayMode): void {
this.reload();
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void {
super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
const reloadIfOneOfProps = ["height", "tileLayerUrl", "minZoom", "maxZoom", "tileLayerAttribution", "plugins.zoomControl"];
if(reloadIfOneOfProps.Contains(p => p.Equals(propertyPath))) {
this.reload();
}
}
private reload(): void {
setTimeout(() => {
const spinner: React.ReactElement<ISpinnerProps> = React.createElement(Spinner, {
});
ReactDom.render(spinner, this.domElement);
setTimeout(() => { this.render(); }, 300);
}, 500);
}
// protected get disableReactivePropertyChanges(): boolean {
// return true;
// }
private onMarkerCategoriesChanged(markerCategories: IMarkerCategory[]): void {
this.properties.markerCategories = markerCategories;
this.render();
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) {
return;
}
this._isDarkTheme = !!currentTheme.isInverted;
const {
semanticColors
} = currentTheme;
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText);
this.domElement.style.setProperty('--link', semanticColors.link);
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
groups: [
{
groupName: strings.WebPartPropertyGroupMapSettings,
groupFields: [
// PropertyPaneWebPartInformation({
// description: `<div class='wp-settings-info'>${strings.WebPartPropertySettingsInfoLabel}</div>`,
// key: 'Info_For_3f860b48-1dc3-496d-bd28-b145672289cc'
// }),
PropertyPaneSlider('minZoom', {
label: strings.WebPartPropertyMinZoomLabel,
max: 30,
min: 0,
step: 1
}),
PropertyPaneSlider('maxZoom', {
label: strings.WebPartPropertyMaxZoomLabel,
max: 30,
min: 5,
step: 1
}),
PropertyPaneSlider('height', {
label: strings.WebPartPropertyHeightLabel,
min: 100,
max: 1200,
step: 50
}),
PropertyPaneToggle('scrollWheelZoom', {
label: strings.WebPartPropertyScrollWheelZoomLabel,
}),
PropertyPaneToggle('dragging', {
label: strings.WebPartPropertyMapDraggingLabel,
}),
PropertyPaneToggle('showPopUp', {
label: strings.WebPartPropertyShowPopUpLabel,
}),
]
},
{
isCollapsed: true,
groupName: strings.WebPartPropertyGroupTileLayerSettings,
groupFields: [
PropertyPaneWebPartInformation({
description: `<div class='wp-settings-info'>${strings.WebPartPropertyTileLayerUrlInformationLabel}</div>`,
key: 'Tile_For_3f860b48-1dc3-496d-bd28-b145672289cc'
}),
PropertyPaneTextField('tileLayerUrl', {
label: strings.WebPartPropertyTileLayerUrlLabel
}),
PropertyPaneTextField('tileLayerAttribution', {
label: strings.WebPartPropertyTileLayerAttributionLabel
}),
]
},
{
isCollapsed: true,
groupName: strings.WebPartPropertyGroupPlugins,
groupFields: [
PropertyPaneToggle('plugins.searchBox', {
label: strings.WebPartPropertyPluginSearchboxLabel
}),
PropertyPaneToggle('plugins.markercluster', {
label: strings.WebPartPropertyPluginMarkerClusterLabel,
}),
PropertyPaneToggle('plugins.zoomControl', {
label: strings.WebPartPropertyPluginZoomControlLabel
}),
]
},
{
isCollapsed: true,
groupName: strings.WebPartPropertyGroupCategories,
groupFields: [
PropertyPaneButton(null, {
text: strings.WebPartPropertyButtonManageCategories,
onClick: (val: any) => {
const dummyElement: HTMLDivElement = document.createElement("div");
document.body.appendChild(dummyElement);
const element: React.ReactElement<IManageMarkerCategoriesDialogProps> = React.createElement(ManageMarkerCategoriesDialog, {
markerCategories: this.properties.markerCategories,
onDismiss: () => {
dummyElement.remove();
},
onMarkerCategoriesChanged: (markerCategories: IMarkerCategory[]) => {
dummyElement.remove();
this.onMarkerCategoriesChanged(markerCategories);
},
});
ReactDom.render(element, dummyElement);
return null;
}
}),
PropertyPaneToggle('plugins.legend', {
label: strings.WebPartPropertyPluginLegendLabel
})
]
},
{
groupName: strings.WebPartPropertyGroupAbout,
groupFields: [
PropertyPaneWebPartInformation({
description: `<h3>Author</h3>
<a href='https://spfx-app.dev/' data-interception="off" target='_blank'>SPFx-App.dev</a>
<h3>Version</h3>
${this.context.manifest.version}
<h3>Web Part Instance id</h3>
${this.context.instanceId}`,
moreInfoLink: `https://spfxappdev.github.io/sp-map-webpart/`,
key: '3f860b48-1dc3-496d-bd28-b145672289cc'
})
]
}
],
displayGroupsAsAccordion: true,
}
]
};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,387 @@
import * as React from 'react';
import { IMarker, IMarkerCategory, MarkerType } from './IMapProps';
import './Map.module.scss';
import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { Icon, Panel, TextField, IPanelProps, PrimaryButton, DefaultButton, IChoiceGroupOption, ChoiceGroup, IDropdownOption, Dropdown, getColorFromString, IColor, PanelType, Label, TooltipHost } from 'office-ui-fabric-react';
import { Guid } from '@microsoft/sp-core-library';
import { isNullOrEmpty, isFunction } from '@spfxappdev/utility';
import { InlineColorPicker } from '@src/components/inlineColorPicker/InlineColorPicker';
import { RichText } from "@pnp/spfx-controls-react/lib/RichText";
import '@spfxappdev/utility/lib/extensions/StringExtensions';
import '@spfxappdev/utility/lib/extensions/ArrayExtensions';
import ManageMarkerCategoriesDialog from './ManageMarkerCategoriesDialog';
import { MarkerIcon } from './MarkerIcon';
import * as strings from 'MapWebPartStrings';
import { IconPicker } from '@src/components/iconPicker/IconPicker';
export interface IAddOrEditPanelProps {
markerItem: IMarker;
markerCategories: IMarkerCategory[];
onDismiss();
onMarkerChanged(markerItem: IMarker, isNewMarker: boolean);
onDeleteMarker(markerItem: IMarker);
onChangePositionClick(markerItem: IMarker);
onMarkerCategoriesChanged(markerCategories: IMarkerCategory[]);
}
interface IAddOrEditPanelState {
markerItem: IMarker;
markerCategories: IMarkerCategory[];
isSaveButtonDisabled: boolean;
isManageCategoriesDialogVisible: boolean;
}
export default class AddOrEditPanel extends React.Component<IAddOrEditPanelProps, IAddOrEditPanelState> {
public state: IAddOrEditPanelState = {
markerItem: cloneDeep(this.props.markerItem),
markerCategories: cloneDeep(this.props.markerCategories),
isSaveButtonDisabled: false,
isManageCategoriesDialogVisible: false
};
private readonly isNewMarker: boolean;
private readonly headerText: string;
private markerTypeOptions: IChoiceGroupOption[] = [
{ key: 'Panel', text: strings.ChoiceGroupPanelLabel, iconProps: { iconName: 'SidePanel' } },
{ key: 'Dialog', text: strings.ChoiceGroupDialogLabel, iconProps: { iconName: 'Favicon' } },
{ key: 'Url', text: strings.ChoiceGroupUrlLabel, iconProps: { iconName: 'Link' } },
{ key: 'None', text: strings.ChoiceGroupNoneLabel, iconProps: { iconName: 'FieldEmpty' } },
];
private urlOptions: IChoiceGroupOption[] = [
{ key: '_self', text: strings.ChoiceGroupTargetSelfLabel },
{ key: '_blank', text: strings.ChoiceGroupTargetBlankLabel },
{ key: 'embedded', text: strings.ChoiceGroupTargetEmbeddedLabel },
];
constructor(props: IAddOrEditPanelProps) {
super(props);
this.isNewMarker = this.props.markerItem.id.Equals(Guid.empty.toString());
this.headerText = this.isNewMarker ? strings.PanelHeaderNewLabel : strings.PanelHeaderEditLabel;
}
public componentDidUpdate(prevProps: Readonly<IAddOrEditPanelProps>, prevState: Readonly<IAddOrEditPanelState>, snapshot?: any): void {
if(!JSON.stringify(prevProps.markerCategories).Equals(JSON.stringify(this.props.markerCategories)) ||
!JSON.stringify(prevProps.markerItem).Equals(JSON.stringify(this.props.markerItem))) {
this.setState({
markerCategories: cloneDeep(this.props.markerCategories),
markerItem: cloneDeep(this.props.markerItem),
isSaveButtonDisabled: false
});
}
}
public render(): React.ReactElement<IAddOrEditPanelProps> {
const selectedCatId: string = this.state.markerCategories.Contains(cat => cat.id.Equals(this.state.markerItem.categoryId)) ? this.state.markerItem.categoryId : Guid.empty.toString();
return (
<Panel
type={PanelType.medium}
isOpen={true}
onDismiss={() => { this.onConfigPanelDismiss(); }}
headerText={this.headerText}
closeButtonAriaLabel={strings.CloseLabel}
onRenderFooterContent={(props: IPanelProps) => {
return this.renderPanelFooter();
}}
// Stretch panel content to fill the available height so the footer is positioned
// at the bottom of the page
isFooterAtBottom={true}
>
<Label>
{strings.LabelCategory}
<span
onClick={() => {
this.setState({
isManageCategoriesDialogVisible: true
});
}}
className='manage-categories-label'>
({strings.LabelManage})
</span>
</Label>
<Dropdown
placeholder={strings.PlaceholderSelectACategory}
defaultSelectedKey={selectedCatId}
onChange={(ev: any, option: IDropdownOption) => {
this.state.markerItem.categoryId = option.key.toString();
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
options={this.categoryOptions}
/>
<ChoiceGroup
label={strings.LabelMarkerType}
defaultSelectedKey={this.state.markerItem.type}
onChange={(ev: any, option: IChoiceGroupOption) => {
this.state.markerItem.type = option.key.toString() as MarkerType;
// if( this.state.markerItem.type == "None") {
// this.state.markerItem.markerClickProps = undefined;
// }
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
options={this.markerTypeOptions} />
{this.renderNonCategorySettings()}
{this.renderUrlSettings()}
{this.renderPanelOrDialogSettings()}
{this.renderManageCategoriesDialog()}
</Panel>
);
}
private renderPanelFooter(): JSX.Element {
return (<div className='panel-footer'>
<PrimaryButton
text={strings.SaveLabel}
disabled={this.state.isSaveButtonDisabled}
onClick={() => {
if(this.isNewMarker) {
this.state.markerItem.id = Guid.newGuid().toString();
}
this.onSaveMarkerClick(this.state.markerItem);
}}
/>
{!this.isNewMarker &&
<>
<DefaultButton text={strings.DeleteLabel} onClick={() => { this.onDeleteMarkerClick(this.state.markerItem); }} />
<DefaultButton text={strings.ChangePositionLabel} onClick={() => { this.onChangePositionClick(this.state.markerItem); }} />
</>
}
<DefaultButton text={strings.CancelLabel} onClick={() => { this.onConfigPanelDismiss(); }} />
</div>);
}
private renderNonCategorySettings(): JSX.Element {
if(this.state.markerCategories.Contains(cat => cat.id.Equals(this.state.markerItem.categoryId))) {
return (<></>);
}
return (
<>
<InlineColorPicker
label={strings.LabelMarkerColor}
alphaType='none'
color={getColorFromString(this.state.markerItem.iconProperties.markerColor)}
onChange={(ev: any, color: IColor) => {
this.state.markerItem.iconProperties.markerColor = "#" + color.hex;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
/>
{/* <TextField label={strings.LabelIcon} description={strings.LabelLeaveEmpty} defaultValue={this.state.markerItem.iconProperties.iconName} onChange={(ev: any, iconName: string) => {
this.state.markerItem.iconProperties.iconName = iconName;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}} /> */}
<IconPicker
label={strings.LabelIcon}
description={strings.LabelLeaveEmpty}
defaultValue={this.state.markerItem.iconProperties.iconName}
onIconChanged={(iconName: string) => {
this.state.markerItem.iconProperties.iconName = iconName;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
/>
<InlineColorPicker
label={strings.LabelIconColor}
alphaType='none'
color={getColorFromString(this.state.markerItem.iconProperties.iconColor)}
onChange={(ev: any, color: IColor) => {
this.state.markerItem.iconProperties.iconColor = "#" + color.hex;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
isDisbaled={isNullOrEmpty(this.state.markerItem.iconProperties.iconName)}
/>
<Label>
{strings.LabelTooltip}
<TooltipHost content={strings.TooltipInfo}>
<Icon className='info-tooltip' iconName='Info' />
</TooltipHost>
</Label>
<TextField description={strings.LabelLeaveEmptyTooltip} defaultValue={this.state.markerItem.popuptext} onChange={(ev: any, popuptext: string) => {
this.state.markerItem.popuptext = popuptext;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}} />
<Label>{strings.LabelPreview}</Label>
<div style={{position: "relative", height: "36px", }}>
<div style={{position: "absolute"}}>
<MarkerIcon {...this.state.markerItem.iconProperties} />
</div>
</div>
</>
);
}
private renderPanelOrDialogSettings(): JSX.Element {
if(!(this.state.markerItem.type == "Dialog" || this.state.markerItem.type == "Panel")) {
return (<></>);
}
const headerLabel: string = this.state.markerItem.type == "Dialog" ? strings.LabelDialogHeader : strings.LabelPanelHeader;
return (<>
<TextField label={headerLabel} defaultValue={this.state.markerItem.markerClickProps.content.headerText} onChange={(ev: any, headerText: string) => {
this.state.markerItem.markerClickProps.content.headerText = headerText;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}} />
<Label>{strings.LabelContent}</Label>
<RichText isEditMode={true} value={this.state.markerItem.markerClickProps.content.html} onChange={(content: string): string => {
this.state.markerItem.markerClickProps.content.html = content;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
return content;
}} />
</>);
}
private renderUrlSettings(): JSX.Element {
if(this.state.markerItem.type != "Url") {
return (<></>);
}
return (
<>
<TextField label={strings.LabelUrl} type='url' defaultValue={this.state.markerItem.markerClickProps.url.href} onChange={(ev: any, url: string) => {
this.state.markerItem.markerClickProps.url.href = url;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}} />
<ChoiceGroup
defaultSelectedKey={this.state.markerItem.markerClickProps.url.target}
options={this.urlOptions}
onChange={(ev: any, option: IChoiceGroupOption) => {
(this.state.markerItem.markerClickProps.url.target as any) = option.key;
this.setState({
markerItem: this.state.markerItem,
isSaveButtonDisabled: false
});
}}
/>
</>
);
}
private renderManageCategoriesDialog(): JSX.Element {
if(!this.state.isManageCategoriesDialogVisible) {
return (<></>);
}
return (
<>
<ManageMarkerCategoriesDialog
markerCategories={this.props.markerCategories}
onDismiss={() => {
this.setState({
isManageCategoriesDialogVisible: false
});
}}
onMarkerCategoriesChanged={(markerCategories: IMarkerCategory[]) => {
this.setState({
isManageCategoriesDialogVisible: false,
markerCategories: markerCategories
});
if(isFunction(this.props.onMarkerCategoriesChanged)) {
this.props.onMarkerCategoriesChanged(markerCategories);
}
}}
/>
</>
);
}
private onConfigPanelDismiss(): void {
if(isFunction(this.props.onDismiss)) {
this.props.onDismiss();
}
}
private onSaveMarkerClick(marker: IMarker): void {
if(isFunction(this.props.onMarkerChanged)) {
this.props.onMarkerChanged(marker, this.isNewMarker);
}
}
private onDeleteMarkerClick(marker: IMarker): void {
if(isFunction(this.props.onDeleteMarker)) {
this.props.onDeleteMarker(marker);
}
}
private onChangePositionClick(marker: IMarker): void {
if(isFunction(this.props.onChangePositionClick)) {
this.props.onChangePositionClick(marker);
}
}
private get categoryOptions(): IDropdownOption[] {
const categories: IDropdownOption[] = [
{ key: Guid.empty.toString(), text: 'None' }
];
this.state.markerCategories.forEach((category: IMarkerCategory) => {
categories.push({ key: category.id, text: category.name });
});
return categories;
}
}

View File

@ -0,0 +1,86 @@
import { Guid } from '@microsoft/sp-core-library';
import * as L from 'leaflet';
import { IMapPlugins } from '../MapWebPart';
export type MarkerType = "Panel"|"Dialog"|"Url"|"None";
export interface IMarkerClickProps {
url: IMarkerUrlProperties;
content: IMarkerContentProperties;
}
export interface IMarkerUrlProperties {
href: string;
target: '_self'|'_blank'|'embedded';
}
export interface IMarkerContentProperties {
headerText: string;
html: string;
}
export interface IMarkerIcon {
markerColor: string;
iconName: string;
iconColor: string;
}
export interface IMarkerCategory {
id: string;
name: string;
popuptext?: string;
iconProperties: IMarkerIcon;
}
export interface IMarker {
id: string;
longitude: number;
latitude: number;
type: MarkerType;
categoryId: string;
iconProperties?: IMarkerIcon;
popuptext?: string;
markerClickProps?: IMarkerClickProps;
}
export interface IMapProps {
markerItems: IMarker[];
markerCategories: IMarkerCategory[];
isEditMode: boolean;
zoom?: number;
center?: [number, number];
maxZoom?: number;
minZoom?: number;
title?: string;
height: number;
dragging: boolean;
scrollWheelZoom: boolean;
plugins: IMapPlugins;
tileLayerUrl: string;
tileLayerAttribution: string;
showPopUp: boolean;
onMarkerCollectionChanged(markerItems: IMarker[]);
onMarkerCategoriesChanged(markerCategories: IMarkerCategory[]);
onStartViewSet(zoomLevel: number, lat: number, lng: number);
onTitleUpdate?: (value: string) => void;
}
export const emptyMarkerItem: IMarker = {
id: Guid.empty.toString(),
latitude: 0,
longitude: 0,
type: "Panel",
markerClickProps: {
url: { href: "", target: '_blank' },
content: { html: '', headerText: '' }
},
categoryId: Guid.empty.toString(),
iconProperties: {
markerColor: "#000000",
iconName: "FullCircleMask",
iconColor: "#ffffff"
},
popuptext: null
};

View File

@ -0,0 +1,240 @@
import * as React from 'react';
import { IMarkerCategory } from './IMapProps';
import './Map.module.scss';
import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { Icon, Dialog, TextField, PrimaryButton, DefaultButton, getColorFromString, IColor, DialogFooter, DialogContent, DialogType, MessageBar, TooltipHost } from 'office-ui-fabric-react';
import { Guid } from '@microsoft/sp-core-library';
import { isNullOrEmpty, isFunction } from '@spfxappdev/utility';
import { InlineColorPicker } from '@src/components/inlineColorPicker/InlineColorPicker';
import '@spfxappdev/utility/lib/extensions/StringExtensions';
import '@spfxappdev/utility/lib/extensions/ArrayExtensions';
import { IconButton } from '@microsoft/office-ui-fabric-react-bundle';
import { MarkerIcon } from './MarkerIcon';
import * as strings from 'MapWebPartStrings';
import { IconPicker } from '@src/components/iconPicker/IconPicker';
export interface IManageMarkerCategoriesDialogProps {
markerCategories: IMarkerCategory[];
onDismiss();
onMarkerCategoriesChanged(markerCategories: IMarkerCategory[]);
}
interface IManageMarkerCategoriesDialogState {
markerCategories: IMarkerCategory[];
isSaveButtonDisabled: boolean;
isNewFormVisible: boolean;
isDialogVisible: boolean;
}
export default class ManageMarkerCategoriesDialog extends React.Component<IManageMarkerCategoriesDialogProps, IManageMarkerCategoriesDialogState> {
public state: IManageMarkerCategoriesDialogState = {
markerCategories: cloneDeep(this.props.markerCategories),
isSaveButtonDisabled: false,
isNewFormVisible: false,
isDialogVisible: true
};
constructor(props: IManageMarkerCategoriesDialogProps) {
super(props);
}
public componentDidMount(): void {
this.validateForm();
}
public render(): React.ReactElement<IManageMarkerCategoriesDialogProps> {
return (
<Dialog
hidden={!this.state.isDialogVisible}
onDismiss={() => { this.onDialogDismiss(); }}
dialogContentProps={{
title: strings.DialogTitleManageCategories,
type: DialogType.close
}}
minWidth={800}
modalProps={{
isBlocking: true,
className: "categories-dialog"
}}
>
<DialogContent>
<div className='spfxappdev-grid'>
<MessageBar className='category-messagebar'>
{isNullOrEmpty(this.state.markerCategories) && <>{strings.InfoTextNoCategories} </>}{strings.InfoTextCategories}
</MessageBar>
{!isNullOrEmpty(this.state.markerCategories) &&
<>
<div className='spfxappdev-grid-row grid-header'>
<div className='spfxappdev-grid-col spfxappdev-sm1'></div>
<div className='spfxappdev-grid-col spfxappdev-sm3'>{strings.LabelCategoryHeaderName}</div>
<div className='spfxappdev-grid-col spfxappdev-sm1'>{strings.LabelMarkerColor}</div>
<div className='spfxappdev-grid-col spfxappdev-sm3'>
{strings.LabelIcon}
<TooltipHost content={strings.LabelLeaveEmpty}>
<Icon className='info-tooltip' iconName='Info' />
</TooltipHost></div>
<div className='spfxappdev-grid-col spfxappdev-sm1'>{strings.LabelIconColor}</div>
<div className='spfxappdev-grid-col spfxappdev-sm2'>
{strings.LabelTooltip}
<TooltipHost content={strings.TooltipInfoCategory}>
<Icon className='info-tooltip' iconName='Info' />
</TooltipHost>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm1'></div>
</div>
{this.state.markerCategories.map((cat: IMarkerCategory, index: number): JSX.Element => {
return (<div key={cat.id} className='spfxappdev-grid-row categories-grid' data-catid={cat.id}>
{this.renderForm(cat, index)}
</div>);
})}
</>
}
<div className='spfxappdev-grid-row grid-footer'>
<div className='spfxappdev-grid-col spfxappdev-sm12'>
<PrimaryButton
text={strings.AddLabel}
onClick={() => {
this.onAddNewCatagoryButtonClick();
}} />
</div>
</div>
</div>
</DialogContent>
<DialogFooter>
<PrimaryButton
onClick={() => {
if(isFunction(this.props.onMarkerCategoriesChanged)) {
this.props.onMarkerCategoriesChanged(this.state.markerCategories);
}
this.setState({
isDialogVisible: false
});
}}
text={strings.SaveLabel}
disabled={this.state.isSaveButtonDisabled}
/>
<DefaultButton onClick={() => {
this.onDialogDismiss();
}} text={strings.CancelLabel} />
</DialogFooter>
</Dialog>);
}
private renderForm(categoryItem: IMarkerCategory, index: number): JSX.Element {
return (
<>
<div className='spfxappdev-grid-col spfxappdev-sm1'>
<IconButton iconProps={{iconName: "Delete"}} onClick={() => {
this.state.markerCategories.RemoveAt(index);
this.validateForm();
}} />
</div>
<div className='spfxappdev-grid-col spfxappdev-sm3'>
<TextField
required={true}
defaultValue={categoryItem.name}
onChange={(ev: any, name: string) => {
this.state.markerCategories[index].name = name;
this.validateForm();
}}
/>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm1'>
<InlineColorPicker
alphaType='none'
color={getColorFromString(categoryItem.iconProperties.markerColor)}
onChange={(ev: any, color: IColor) => {
this.state.markerCategories[index].iconProperties.markerColor = "#" + color.hex;
this.validateForm();
}}
/>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm3'>
<IconPicker
defaultValue={categoryItem.iconProperties.iconName}
onIconChanged={(name: string) => {
this.state.markerCategories[index].iconProperties.iconName = name;
this.validateForm();
}}
/>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm1'>
<InlineColorPicker
alphaType='none'
color={getColorFromString(categoryItem.iconProperties.iconColor)}
onChange={(ev: any, color: IColor) => {
this.state.markerCategories[index].iconProperties.iconColor = "#" + color.hex;
this.validateForm();
}}
isDisbaled={isNullOrEmpty(categoryItem.iconProperties.iconName)}
/>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm2'>
<TextField
defaultValue={categoryItem.popuptext}
onChange={(ev: any, popuptext: string) => {
this.state.markerCategories[index].popuptext = popuptext;
this.validateForm();
}}
/>
</div>
<div className='spfxappdev-grid-col spfxappdev-sm1'>
<div style={{position: "absolute"}}>
<MarkerIcon {...categoryItem.iconProperties} />
</div>
</div>
</>
);
}
private validateForm(): void {
const isSaveBtnDisabled = this.state.markerCategories.Contains(cat => isNullOrEmpty(cat.name) || isNullOrEmpty(cat.iconProperties.markerColor));
this.setState({
markerCategories: this.state.markerCategories,
isSaveButtonDisabled: isSaveBtnDisabled
});
}
private onAddNewCatagoryButtonClick(): void {
this.state.markerCategories.push(this.createNewCatagoryItem());
this.validateForm();
}
private createNewCatagoryItem(): IMarkerCategory {
const category: IMarkerCategory = {
id: Guid.newGuid().toString(),
name: "",
iconProperties: {
markerColor: "#000000",
iconName: "FullCircleMask",
iconColor: "#ffffff"
}
};
return category;
}
private onDialogDismiss(): void {
this.setState({
isDialogVisible: false
});
this.props.onDismiss();
}
}

View File

@ -0,0 +1,223 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.map {
display: block;
min-height: 400px;
position: relative;
}
:global {
.map-icon {
position: absolute;
left: 8px;
top: 5px;
color: #fff;
i {
font-size: 12px;
}
}
.display-mode .marker-type-none {
cursor: default !important;
}
.manage-categories-label {
font-size: 10px;
color: #1190F4;
padding-left: 5px;
cursor: pointer;
}
.spfxappdev-grid {
box-sizing: border-box;
zoom: 1;
padding: 0 8px;
&::before,
&::after {
display: table;
content: '';
line-height: 0;
-webkit-box-sizing: inherit;
box-sizing: inherit;
}
&::after {
clear: both;
}
&-row {
margin: 0 -8px;
box-sizing: border-box;
zoom: 1;
&::before,
&::after {
display: table;
content: '';
line-height: 0;
-webkit-box-sizing: inherit;
box-sizing: inherit;
}
&::after {
clear: both;
}
}
&-col {
position: relative;
min-height: 1px;
// padding-left: 8px;
// padding-right: 8px;
box-sizing: border-box;
float: left;
}
}
.spfxappdev-sm1 {
width: 8.33%
}
.spfxappdev-sm2 {
width: 16.66%;
}
.spfxappdev-sm3 {
width: 25%;
}
.spfxappdev-sm4 {
width: 33.33%;
}
.spfxappdev-sm5 {
width: 41.66%;
}
.spfxappdev-sm6 {
width: 50%;
}
.spfxappdev-sm7 {
width: 58.33%;
}
.spfxappdev-sm8 {
width: 66.66%;
}
.spfxappdev-sm9 {
width:75%;
}
.spfxappdev-sm10 {
width: 83.33%;
}
.spfxappdev-sm11 {
width: 91.66%;
}
.spfxappdev-sm12 {
width: 100%;
}
.grid-header {
background: $ms-color-themePrimary;
color: #fff;
font-weight: 600;
text-align: center;
}
.grid-footer {
margin-top: 10px
}
.categories-grid {
.spfxappdev-grid-col {
padding: 10px 2px;
}
&.spfxappdev-grid-row {
border-bottom: solid 1px #aeaeae;
&:nth-child(odd) {
background: $ms-color-themeLighter;
}
}
}
.iframe-dialog,
.categories-dialog {
.ms-Dialog-content .ms-Dialog-header {
display: none;
}
}
.iframe-dialog {
iframe {
border: 0;
width: 100%;
height: 100%;
}
}
.ql-editor[contenteditable='true'] {
border: solid 1px $ms-color-themeDarker !important;
}
.panel-footer {
button {
margin: 0 5px;
}
}
.leaflet-popup-close-button {
display: none;
}
.leaflet-popup-tip-container {
margin-top: -1px;
}
.change-position-popup {
text-align: center;
label {
font-size: 24px;
font-weight: 600;
}
button {
margin: 0 5px;
}
}
.info-tooltip {
font-size: 10px;
padding-left: 4px;
vertical-align: middle;
cursor:help;
}
.wp-settings-info {
font-weight: 600;
padding: 20px 0;
color: $ms-color-themeDarker;
}
.category-messagebar {
margin-bottom: 10px;
}
.CanvasControlToolbar {
z-index: 2000 !important;
}
}

View File

@ -0,0 +1,582 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import styles from './Map.module.scss';
import { IMapProps, IMarker, IMarkerCategory, IMarkerIcon, emptyMarkerItem } from './IMapProps';
import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import "leaflet/dist/leaflet.css";
import "react-leaflet-markercluster/dist/styles.min.css";
import * as L from 'leaflet';
import { ContextualMenu, IContextualMenuItem, Panel, Dialog, IPanelProps, DefaultButton, PanelType, DialogType, DialogContent, Label, Separator, PrimaryButton } from 'office-ui-fabric-react';
import { isset, isNullOrEmpty, getDeepOrDefault, cssClasses } from '@spfxappdev/utility';
import '@spfxappdev/utility/lib/extensions/StringExtensions';
import '@spfxappdev/utility/lib/extensions/ArrayExtensions';
import { DisplayMode } from '@microsoft/sp-core-library';
import { RichText } from "@pnp/spfx-controls-react/lib/RichText";
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import AddOrEditPanel from './AddOrEditPanel';
import { isFunction } from 'lodash';
import { MarkerIcon } from './MarkerIcon';
import MarkerClusterGroup from 'react-leaflet-markercluster';
import * as strings from 'MapWebPartStrings';
import SearchPlugin from './plugins/search/SearchPlugin';
import LegendPlugin from './plugins/legend/LegendPlugin';
interface IMapState {
markerItems: IMarker[];
markerCategories: IMarkerCategory[];
rightMouseTarget?: any;
showAddOrEditMarkerPanel: boolean;
currentMarker?: IMarker;
showClickContent: boolean;
changePositionMarkerId: string;
}
export default class Map extends React.Component<IMapProps, IMapState> {
public state: IMapState = {
markerItems: cloneDeep(this.props.markerItems),
markerCategories: cloneDeep(this.props.markerCategories),
showAddOrEditMarkerPanel: false,
showClickContent: false,
changePositionMarkerId: '-1'
};
private allCatagories: Record<string, IMarkerCategory> = {};
private menuItems: IContextualMenuItem[] = [
{
key: 'newItem',
text: strings.ContextMenuAddNewMarkerLabel,
onClick: () => {
this.onCreateNewMarkerContextMenuItemClick();
}
},
{
key: 'setStartView',
text: strings.ContextMenuSetStartPositionLabel,
onClick: () => {
this.onSetStartView();
}
}
];
private map: L.Map = null;
private allLeafletMarker: Record<string, L.Marker> = {};
private lastLatLngRightClickPosition: L.LatLng;
constructor(props: IMapProps) {
super(props);
this.setAllCatagoriesDictionary();
}
public componentDidUpdate(prevProps: Readonly<IMapProps>, prevState: Readonly<IMapState>, snapshot?: any): void {
if(!JSON.stringify(prevProps.markerCategories).Equals(JSON.stringify(this.props.markerCategories))) {
this.setState({
markerCategories: cloneDeep(this.props.markerCategories)
}, () => {
this.setAllCatagoriesDictionary();
});
}
}
public render(): React.ReactElement<IMapProps> {
this.allLeafletMarker = {};
// const isZoomControlEnabled: boolean = this.props.isEditMode ? true : getDeepOrDefault<boolean>(this.props, "plugins.zoomControl", true);
const isZoomControlEnabled: boolean = getDeepOrDefault<boolean>(this.props, "plugins.zoomControl", true);
const isScrollWheelZoomEnabled: boolean = this.props.isEditMode ? true : getDeepOrDefault<boolean>(this.props, "scrollWheelZoom", true);
const isDraggingEnabled: boolean = this.props.isEditMode ? true : getDeepOrDefault<boolean>(this.props, "dragging", true);
//
return (
<div className={styles.map}>
{(this.props.isEditMode || (!this.props.isEditMode && !isNullOrEmpty(this.props.title))) &&
<WebPartTitle displayMode={this.props.isEditMode?DisplayMode.Edit:DisplayMode.Read}
title={this.props.title}
updateProperty={this.props.onTitleUpdate} />
}
<MapContainer
className={this.props.isEditMode ? "edit-mode" : "display-mode"}
zoomControl={isZoomControlEnabled}
center={this.props.center}
zoom={this.props.zoom}
maxZoom={this.props.maxZoom}
minZoom={this.props.minZoom}
scrollWheelZoom={isScrollWheelZoomEnabled}
touchZoom={isScrollWheelZoomEnabled}
doubleClickZoom={isScrollWheelZoomEnabled}
dragging={isDraggingEnabled}
whenCreated={(map: L.Map) => {
map.on("contextmenu", (ev: L.LeafletEvent) => {
if (!this.props.isEditMode) {
return;
}
this.lastLatLngRightClickPosition = (ev as any).latlng;
this.setState({
rightMouseTarget: {
x: ((ev as any).originalEvent as MouseEvent).clientX,
y: ((ev as any).originalEvent as MouseEvent).clientY
}
});
});
this.map = map;
}
}
style={{height: isNullOrEmpty(this.props.height) ? "400px" : `${this.props.height}px`}}
>
<TileLayer
attribution={`<a href="https://spfx-app.dev/">SPFx-App.dev</a> | ${this.props.tileLayerAttribution}`}
url={this.props.tileLayerUrl}
/>
{this.props.plugins.markercluster &&
<MarkerClusterGroup>
{this.renderMarker()}
</MarkerClusterGroup>
}
{!this.props.plugins.markercluster &&
this.renderMarker()
}
{this.renderSearchBox()}
{this.renderLegend(isZoomControlEnabled)}
</MapContainer>
{this.props.isEditMode &&
<ContextualMenu
items={this.menuItems}
hidden={typeof this.state.rightMouseTarget == "undefined"}
target={this.state.rightMouseTarget}
onItemClick={() => {
}}
onDismiss={() => {
this.setState({
rightMouseTarget: undefined
});
}}
/>
}
{this.showAddOrEditMarkerPanel()}
{this.showClickContent()}
</div>
);
}
private renderMarker(): JSX.Element {
return (
<>
{this.state.markerItems.map((marker: IMarker, index: number): JSX.Element => {
const useCategory: boolean = isset(this.allCatagories[marker.categoryId]);
const markerCategory: IMarkerCategory = useCategory ? this.allCatagories[marker.categoryId] : null;
const popupText: string = !useCategory ? marker.popuptext : isNullOrEmpty(markerCategory.popuptext) ? markerCategory.name : markerCategory.popuptext;
const isDraggable: boolean = marker.id.Equals(this.state.changePositionMarkerId);
return (
<Marker
draggable={isDraggable}
position={[marker.latitude, marker.longitude]}
key={`marker_${marker.id}`}
icon={this.createIcon(marker, markerCategory)}
ref={(ref: L.Marker) => {
if(!isset(ref)) {
return;
}
this.allLeafletMarker[marker.id] = ref;
if(this.state.changePositionMarkerId.Equals(marker.id)) {
setTimeout(() => {
ref.openPopup();
}, 300);
}
}}
eventHandlers={
{
click: (ev: L.LeafletMouseEvent) => {
if(this.state.changePositionMarkerId.length >= 32) {
return;
}
let showEditPanel: boolean = this.props.isEditMode;
this.setState({
currentMarker: marker,
showClickContent: !showEditPanel,
showAddOrEditMarkerPanel: showEditPanel
});
},
mouseover: (ev: L.LeafletMouseEvent) => {
if(!this.props.showPopUp) {
return;
}
if(this.state.changePositionMarkerId.length >= 32) {
return;
}
(ev.target as any).openPopup();
},
mouseout: (ev: L.LeafletMouseEvent) => {
if(!this.props.showPopUp) {
return;
}
if(this.state.changePositionMarkerId.length >= 32) {
return;
}
(ev.target as any).closePopup();
},
dragend: (ev: L.DragEndEvent) => {
const currentMarker = (ev.target as any);
setTimeout(() => {
if(isset(marker)) {
currentMarker.openPopup();
}
}, 300);
}
}
}
>
{this.props.showPopUp && this.state.changePositionMarkerId != marker.id && !isNullOrEmpty(popupText) &&
<Popup>
{popupText}
</Popup>
}
{this.state.changePositionMarkerId == marker.id &&
<Popup>
<div className="change-position-popup">
<Label>{strings.LabelChangePosition}</Label>
<Separator />
<PrimaryButton
text={strings.SaveLabel}
onClick={() => {
const currentMarker = this.allLeafletMarker[marker.id];
const latLng: L.LatLng = currentMarker.getLatLng();
this.state.markerItems[index].latitude = latLng.lat;
this.state.markerItems[index].longitude = latLng.lng;
currentMarker.dragging.disable();
this.setState({
changePositionMarkerId: "-1",
showAddOrEditMarkerPanel: true,
markerItems: this.state.markerItems
});
if(isFunction(this.props.onMarkerCollectionChanged)) {
this.props.onMarkerCollectionChanged(this.state.markerItems);
}
}}
/>
<DefaultButton
text={strings.CancelLabel}
onClick={() => {
const currentMarker = this.allLeafletMarker[marker.id];
currentMarker.setLatLng([marker.latitude, marker.longitude]);
currentMarker.dragging.disable();
this.setState({
changePositionMarkerId: "-1",
showAddOrEditMarkerPanel: true
});
}}
/>
</div>
</Popup>
}
</Marker>
);
})}
</>
);
}
private renderLegend(isZoomControlEnabled: boolean): JSX.Element {
if(!getDeepOrDefault<boolean>(this.props, "plugins.legend", false) || isNullOrEmpty(this.state.markerCategories)) {
return (<></>);
}
return (
<LegendPlugin isZoomControlVisible={isZoomControlEnabled} markerCategories={this.state.markerCategories} />
);
}
private renderSearchBox(): JSX.Element {
if(!this.props.plugins.searchBox) {
return (<></>);
}
return (
<SearchPlugin onLocationSelected={(lat: number, lon: number) => {
this.map.setView([lat, lon], this.props.maxZoom > 18 ? 18 : this.props.maxZoom);
const defaultRadius = 12;
const circleOptions = {
inner: {
color: '#136AEC',
fillColor: '#2A93EE',
fillOpacity: 1,
weight: 1.5,
opacity: 0.7,
radius: defaultRadius / 4
},
outer: {
color: "#136AEC",
fillColor: "#136AEC",
fillOpacity: 0.15,
opacity: 0.3,
weight: 1,
radius: defaultRadius
}
};
L.circle([lat, lon], circleOptions.outer).addTo(this.map);
L.circle([lat, lon], circleOptions.inner).addTo(this.map);
}} />
);
}
private showClickContent(): JSX.Element {
if(!this.state.showClickContent || isNullOrEmpty(this.state.currentMarker)) {
return (<></>);
}
if(this.state.currentMarker.type == "None") {
return (<></>);
}
if(this.state.currentMarker.type == "Url" && this.state.currentMarker.markerClickProps.url.target != "embedded") {
window.open(this.state.currentMarker.markerClickProps.url.href, this.state.currentMarker.markerClickProps.url.target);
return (<></>);
}
if (this.state.currentMarker.type == "Panel") {
return (<Panel
type={PanelType.medium}
isOpen={true}
onDismiss={() => { this.onContentPanelOrDialogDismiss(); }}
headerText={this.state.currentMarker.markerClickProps.content.headerText}
closeButtonAriaLabel="Close"
onRenderFooterContent={(props: IPanelProps) => {
return (<div>
<DefaultButton onClick={() => { this.onContentPanelOrDialogDismiss(); }}>Close</DefaultButton>
</div>);
}}
// Stretch panel content to fill the available height so the footer is positioned
// at the bottom of the page
isFooterAtBottom={true}
>
<RichText isEditMode={false} value={this.state.currentMarker.markerClickProps.content.html} />
</Panel>);
}
const width: number = window.innerWidth - 100;
const height: number = window.innerHeight - 300;
let dialogWidth = 900;
if(width < dialogWidth || this.state.currentMarker.type == "Url") {
dialogWidth = width;
}
return (
<Dialog
hidden={false}
onDismiss={() => { this.onContentPanelOrDialogDismiss(); }}
dialogContentProps={{
title: this.state.currentMarker.markerClickProps.content.headerText,
type: DialogType.close
}}
minWidth={dialogWidth}
modalProps={{
isBlocking: true,
className: "iframe-dialog",
}}
>
<DialogContent>
{this.state.currentMarker.type == "Dialog" && <RichText isEditMode={false} value={this.state.currentMarker.markerClickProps.content.html} />}
{this.state.currentMarker.type == "Url" &&
<div style={{height: `${height}px`}}>
<iframe src={this.state.currentMarker.markerClickProps.url.href}></iframe>
</div>
}
</DialogContent>
</Dialog>
);
}
private showAddOrEditMarkerPanel(): JSX.Element {
if(!this.state.showAddOrEditMarkerPanel || !this.props.isEditMode) {
return (<></>);
}
return (
<AddOrEditPanel
markerCategories={this.state.markerCategories}
markerItem={this.state.currentMarker}
onDismiss={() => { this.onConfigPanelDismiss(); }}
onDeleteMarker={(markerItem: IMarker) => {
const markerIndex: number = this.state.markerItems.IndexOf(m => m.id == markerItem.id);
this.state.markerItems.RemoveAt(markerIndex);
if(isFunction(this.props.onMarkerCollectionChanged)) {
this.props.onMarkerCollectionChanged(this.state.markerItems);
}
this.state.rightMouseTarget = undefined;
this.onConfigPanelDismiss();
}}
onChangePositionClick={(markerItem: IMarker) => {
this.setState({
changePositionMarkerId: markerItem.id,
showAddOrEditMarkerPanel: false
});
}}
onMarkerCategoriesChanged={(markerCategories: IMarkerCategory[]) => {
this.state.markerCategories = markerCategories;
if(isFunction(this.props.onMarkerCategoriesChanged)) {
this.props.onMarkerCategoriesChanged(markerCategories);
}
this.setAllCatagoriesDictionary();
this.setState({
markerCategories: markerCategories
});
}}
onMarkerChanged={(markerItem: IMarker, isNewMarker: boolean) => {
if(isNewMarker) {
this.state.markerItems.push(markerItem);
}
else {
const markerIndex: number = this.state.markerItems.IndexOf(m => m.id == markerItem.id);
if(markerIndex >= 0) {
this.state.markerItems[markerIndex] = markerItem;
}
}
this.state.rightMouseTarget = undefined;
if(isFunction(this.props.onMarkerCollectionChanged)) {
this.props.onMarkerCollectionChanged(this.state.markerItems);
}
this.onConfigPanelDismiss();
}}
/>
);
}
private onConfigPanelDismiss(): void {
this.setState({
showAddOrEditMarkerPanel: false,
currentMarker: null
});
}
private onContentPanelOrDialogDismiss(): void {
this.setState({
showClickContent: false,
currentMarker: null
});
}
private createIcon(marker: IMarker, markerCategory: IMarkerCategory ): L.Icon {
const markerIcon = new L.Icon({
iconAnchor: [13, 36],
popupAnchor: [0, -36],
shadowUrl: null,
shadowSize: null,
shadowAnchor: null,
iconSize: new L.Point(27, 36),
className: cssClasses('leaflet-div-icon', `marker-type-${marker.type.toLowerCase()}`)
});
markerIcon.createIcon = (oldIcon: HTMLElement) => {
const wrapper = document.createElement("div");
wrapper.classList.add("leaflet-marker-icon");
wrapper.classList.add(`marker-type-${marker.type.toLowerCase()}`);
wrapper.dataset.markerid = marker.id;
wrapper.style.marginLeft = (markerIcon.options.iconAnchor[0] * -1) + "px";
wrapper.style.marginTop = (markerIcon.options.iconAnchor[1] * -1) + "px";
const iconProperties: IMarkerIcon = isNullOrEmpty(markerCategory) ? marker.iconProperties : markerCategory.iconProperties;
ReactDom.render(<MarkerIcon {...iconProperties} />, wrapper);
return wrapper;
};
return markerIcon as any as L.Icon;
}
private onCreateNewMarkerContextMenuItemClick(): void {
this.state.currentMarker = cloneDeep(emptyMarkerItem);
this.state.currentMarker.latitude = this.lastLatLngRightClickPosition.lat;
this.state.currentMarker.longitude = this.lastLatLngRightClickPosition.lng;
this.state.showAddOrEditMarkerPanel = true;
this.setState({...this.state});
}
private onSetStartView(): void {
if(isFunction(this.props.onStartViewSet)) {
const zoom: number = this.map.getZoom();
const latLng: L.LatLng = this.map.getCenter();
this.props.onStartViewSet(zoom, latLng.lat, latLng.lng);
}
}
private setAllCatagoriesDictionary(): void {
this.allCatagories = {};
this.state.markerCategories.forEach((category: IMarkerCategory) => {
this.allCatagories[category.id] = category;
});
}
}

View File

@ -0,0 +1,21 @@
import { Icon } from 'office-ui-fabric-react';
import * as React from 'react';
import { IMarkerIcon } from './IMapProps';
export const MarkerIcon: React.FunctionComponent<IMarkerIcon> = (iconProperties): JSX.Element => {
const iconColor: React.CSSProperties = {
color: iconProperties.iconColor.slice()
};
return (
<span>
<svg height="36px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill={iconProperties.markerColor}>
{/* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) */}
<path d="M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0z"/>
</svg>
<span className="map-icon" style={iconColor}><Icon iconName={iconProperties.iconName} /></span>
</span>
);
};

View File

@ -0,0 +1,60 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.map-plugin-legend {
display: block;
&-bottom {
top: 80px;
}
button {
outline: none;
border-radius: 2px;
height: 32px;
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
font-size: 1.4em;
background: #fff;
cursor: pointer;
}
}
:global {
.map-legend {
margin: 10px;
&-title {
font-weight: 700;
text-align: center;
font-size: 1.5em;
}
&-marker-item {
display: flex;
padding: 5px 10px;
align-items: center;
&-icon {
width: 35px;
}
label {
margin: 0 10px;
padding: 1px 0;
font-size: 12px;
}
}
&-marker-wrapper {
position: relative;
height: 36px;
// float: left;
& > div {
position: absolute;
}
}
}
}

View File

@ -0,0 +1,82 @@
import { Callout } from '@microsoft/office-ui-fabric-react-bundle';
import { randomString } from '@spfxappdev/utility';
import * as strings from 'MapWebPartStrings';
import { Icon, Label, Separator } from 'office-ui-fabric-react';
import * as React from 'react';
import { IMarkerCategory } from '../../IMapProps';
import { MarkerIcon } from '../../MarkerIcon';
import styles from './LegendPlugin.module.scss';
export interface ILegendPluginProps {
markerCategories: IMarkerCategory[];
isZoomControlVisible: boolean;
}
interface ILegendPluginState {
isCalloutVisible: boolean;
}
export default class LegendPlugin extends React.Component<ILegendPluginProps, ILegendPluginState> {
public state: ILegendPluginState = {
isCalloutVisible: false
};
private randomId: string;
constructor(props: ILegendPluginProps) {
super(props);
this.randomId = `map_legend_${randomString(6)}`;
}
public render(): React.ReactElement<ILegendPluginProps> {
let cssClass = styles['map-plugin-legend'];
if(this.props.isZoomControlVisible) {
cssClass += ' ' + styles['map-plugin-legend-bottom'];
}
return (
<div className={`leaflet-top leaflet-left ${cssClass}`}>
<div className="leaflet-control leaflet-bar">
<button
type="button"
id={this.randomId}
onClick={() => {
const isVisible: boolean = this.state.isCalloutVisible ? false : true;
this.setState({
isCalloutVisible: isVisible
});
}}
>
<Icon iconName="Info" />
</button>
<Callout
target={`#${this.randomId}`}
onDismiss={() => { this.setState({ isCalloutVisible: false }); }}
hidden={!this.state.isCalloutVisible}>
<div className='map-legend'>
<Label className="map-legend-title">{strings.LegendLabel}</Label>
<Separator />
{this.props.markerCategories.map((cat: IMarkerCategory): JSX.Element => {
return (
<div key={`legend_${cat.id}`} className="map-legend-marker-item">
<div className='map-legend-marker-item-icon'>
<div className='map-legend-marker-wrapper'>
<div style={{}}>
<MarkerIcon {...cat.iconProperties} />
</div>
</div>
</div>
<Label>{cat.name}</Label>
</div>);
})}
</div>
</Callout>
</div>
</div>);
}
}

View File

@ -0,0 +1,61 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.map-plugin-search {
display: block;
top: 20px;
right: 20px;
z-index: 1000;
position: absolute;
button {
outline: none;
border-radius: 2px;
height: 32px;
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
font-size: 1.4em;
background: #fff;
cursor: pointer;
}
.textbox {
display: inline-block !important;
border-width: 0px;
padding: 0;
div {
border-width: 0px;
}
}
}
.suggesstion {
display: block;
&-item {
display: flex;
padding: 5px 20px;
border-bottom: solid 1px $ms-color-themeLighter;
border-top: solid 1px $ms-color-themeLighter;
align-items: center;
cursor: pointer;
&:first-child {
border-top-width: 0;
}
&:last-child {
border-bottom-width: 0;
}
i {
padding-right: 20px;
font-size: 2em;
}
span {
font-size: 1em;
}
}
}

View File

@ -0,0 +1,106 @@
import { isFunction, isNullOrEmpty } from '@spfxappdev/utility';
import { Autocomplete } from '@src/components/autocomplete/Autocomplete';
import * as strings from 'MapWebPartStrings';
import { Icon } from 'office-ui-fabric-react';
import * as React from 'react';
import styles from './SearchPlugin.module.scss';
export interface ISearchPluginProps {
nominatimUrl?: string;
resultLimit?: number;
onLocationSelected?(latitude: number, longitude: number): void;
}
interface ISearchResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
}
interface ISearchPluginState {
searchResult: Array<ISearchResult>;
isSearchBoxVisible: boolean;
}
export default class SearchPlugin extends React.Component<ISearchPluginProps, ISearchPluginState> {
public state: ISearchPluginState = {
searchResult: [],
isSearchBoxVisible: false
};
public static defaultProps: ISearchPluginProps = {
nominatimUrl: "https://nominatim.openstreetmap.org/search",
resultLimit: 3
};
public render(): React.ReactElement<ISearchPluginProps> {
return (
<div className={styles["map-plugin-search"]}>
{this.state.isSearchBoxVisible &&
<Autocomplete
className={styles['textbox']}
onRenderSuggestions={(searchTerm: string) => {
return this.renderSuggesstionsFlyout(searchTerm);
}}
onChange={async (ev: any, searchTerm: string) => {
const result = await this.makeSearchRequest(searchTerm);
this.setState({
searchResult: result
});
}} />
}
<button type="button" onClick={() => {
const isVisible: boolean = this.state.isSearchBoxVisible ? false : true;
this.setState({
isSearchBoxVisible: isVisible
});
}}>
<Icon iconName="Search" />
</button>
</div>);
}
private renderSuggesstionsFlyout(searchTerm: string): JSX.Element {
const results = this.state.searchResult;
if(isNullOrEmpty(results)) {
return (<>
{strings.NoSearchResultsLabel}
</>);
}
return (<div className={styles["suggesstion"]}>
{results.map((location: ISearchResult, index: number): JSX.Element => {
return (<div
key={`Icon_${index}_${location.place_id}`}
onClick={() => {
if(isFunction(this.props.onLocationSelected)) {
this.props.onLocationSelected(parseFloat(location.lat), parseFloat(location.lon));
}
this.setState({
isSearchBoxVisible: false
});
}}
className={styles["suggesstion-item"]}>
{location.display_name}
</div>);
})}
</div>);
}
private async makeSearchRequest(searchTerm: string): Promise<ISearchResult[]> {
const response = await fetch(`${this.props.nominatimUrl}?format=json&limit=${this.props.resultLimit}&q=${searchTerm}`);
const responseJson = await response.json();
return responseJson;
}
}

View File

@ -0,0 +1,65 @@
define([], function() {
return {
"WebPartPropertyGroupMapSettings": "General settings",
"WebPartPropertyGroupTileLayerSettings": "Tile Layer",
"WebPartPropertyGroupPlugins": "Plugins/Controls",
"WebPartPropertyGroupCategories": "Categories",
"WebPartPropertyGroupAbout": "About",
"WebPartPropertyPluginSearchboxLabel": "Show Searchbox",
"WebPartPropertyPluginMarkerClusterLabel": "Enable marker cluster",
"WebPartPropertyPluginLegendLabel": "Show legend",
"WebPartPropertyButtonManageCategories": "Manage categories",
"WebPartPropertyPluginZoomControlLabel": "Show zoom control",
"WebPartPropertyScrollWheelZoomLabel": "Enable zoom on mouse wheel/touch (only in display mode)",
"WebPartPropertyMapDraggingLabel": "Enable dragging in map (only in display mode)",
"WebPartPropertyShowPopUpLabel": "Show tooltip when hovering the marker",
"WebPartPropertySettingsInfoLabel": "Most of these settings take effect only after a page refresh and only in display mode",
"WebPartPropertyMinZoomLabel": "Minimum zoom level (zoom out)",
"WebPartPropertyMaxZoomLabel": "Maximum zoom level (zoom in, depends on Tile layer)",
"WebPartPropertyHeightLabel": "Map height (in px)",
"WebPartPropertyTileLayerUrlInformationLabel": "In this section you can change the tile layer and attribution. You can find more tile layers e.g. <a href='https://leaflet-extras.github.io/leaflet-providers/preview/' data-interception='off' target='_blank'>here</a>",
"WebPartPropertyTileLayerUrlLabel": "Tile layer URL",
"WebPartPropertyTileLayerAttributionLabel": "Tile layer attribution",
"ContextMenuAddNewMarkerLabel": "Add a new marker here",
"ContextMenuSetStartPositionLabel": "Make this view as start position",
"LabelChangePosition": "Change position",
"SaveLabel": "Save",
"CancelLabel": "Cancel",
"CloseLabel": "Close",
"LegendLabel": "Legend",
"ChoiceGroupPanelLabel": "Panel",
"ChoiceGroupDialogLabel": "Dialog",
"ChoiceGroupUrlLabel": "Url",
"ChoiceGroupNoneLabel": "None (not clickable)",
"ChoiceGroupTargetSelfLabel": "Open in same window",
"ChoiceGroupTargetBlankLabel": "Open in new window",
"ChoiceGroupTargetEmbeddedLabel": "Embedded (Dialog/iFrame)",
"PanelHeaderNewLabel": "Create new marker",
"PanelHeaderEditLabel": "Edit marker",
"LabelCategory": "Category",
"LabelManage": "Manage",
"PlaceholderSelectACategory": "Select a category",
"LabelMarkerType": "Type of marker (on click)",
"DeleteLabel": "Delete",
"ChangePositionLabel": "Change Position",
"LabelMarkerColor": "Marker color",
"LabelIcon": "Icon",
"LabelLeaveEmpty": "Leave blank to show no icon",
"LabelIconColor": "Icon color",
"LabelTooltip": "Tooltip text",
"TooltipInfoCategory": "For categories, the name of the category is displayed when hovering over the marker (if enabled in the web part settings). This text can be overwritten here.",
"TooltipInfo": "When hovering over the marker, this text is displayed (if enabled in the webpart settings)",
"LabelLeaveEmptyTooltip": "Leave blank to show no tooltip",
"LabelPreview": "Preview",
"LabelPanelHeader": "Panel Header",
"LabelDialogHeader": "Dialog Header",
"LabelContent": "Content",
"LabelUrl": "Url",
"DialogTitleManageCategories": "Manage categories",
"InfoTextNoCategories": "Currently there are no categories.",
"InfoTextCategories": "Markers can be assigned to categories. If you change the category, this change will be applied to all markers assigned to this category. If you delete a category, the default configuration is used.",
"LabelCategoryHeaderName": "Name",
"AddLabel": "Add",
"NoSearchResultsLabel": "No Results"
}
});

View File

@ -0,0 +1,68 @@
declare interface IMapWebPartStrings {
WebPartPropertyGroupMapSettings: string;
WebPartPropertyGroupTileLayerSettings: string;
WebPartPropertyGroupPlugins: string;
WebPartPropertyGroupCategories: string;
WebPartPropertyGroupAbout: string;
WebPartPropertyPluginSearchboxLabel: string;
WebPartPropertyPluginMarkerClusterLabel: string;
WebPartPropertyPluginLegendLabel: string;
WebPartPropertyButtonManageCategories: string;
WebPartPropertyPluginZoomControlLabel: string;
WebPartPropertyScrollWheelZoomLabel: string;
WebPartPropertyMapDraggingLabel: string;
WebPartPropertyShowPopUpLabel: string;
WebPartPropertySettingsInfoLabel: string;
WebPartPropertyMinZoomLabel: string;
WebPartPropertyMaxZoomLabel: string;
WebPartPropertyHeightLabel: string;
WebPartPropertyTileLayerUrlInformationLabel: string;
WebPartPropertyTileLayerUrlLabel: string;
WebPartPropertyTileLayerAttributionLabel: string;
ContextMenuAddNewMarkerLabel: string;
ContextMenuSetStartPositionLabel: string;
LabelChangePosition: string;
SaveLabel: string;
CancelLabel: string;
CloseLabel: string;
LegendLabel: string;
ChoiceGroupPanelLabel: string;
ChoiceGroupDialogLabel: string;
ChoiceGroupUrlLabel: string;
ChoiceGroupNoneLabel: string;
ChoiceGroupTargetSelfLabel: string;
ChoiceGroupTargetBlankLabel: string;
ChoiceGroupTargetEmbeddedLabel: string;
PanelHeaderNewLabel: string;
PanelHeaderEditLabel: string;
LabelCategory: string;
LabelManage: string;
PlaceholderSelectACategory: string;
LabelMarkerType: string;
DeleteLabel: string;
ChangePositionLabel: string;
LabelMarkerColor: string;
LabelIcon: string;
LabelLeaveEmpty: string;
LabelIconColor: string;
LabelTooltip: string;
TooltipInfoCategory: string;
TooltipInfo: string;
LabelLeaveEmptyTooltip: string;
LabelPreview: string;
LabelPanelHeader: string;
LabelDialogHeader: string;
LabelContent: string;
LabelUrl: string;
DialogTitleManageCategories: string;
InfoTextNoCategories: string;
InfoTextCategories: string;
LabelCategoryHeaderName: string;
AddLabel: string;
NoSearchResultsLabel: string;
}
declare module 'MapWebPartStrings' {
const strings: IMapWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

View File

@ -0,0 +1,40 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.9/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"],
"@webparts/*": ["src/webparts/*"]
},
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection",
"es2015.promise"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
]
}

View File

@ -0,0 +1,29 @@
{
"extends": "./node_modules/@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}

View File

@ -4032,9 +4032,9 @@
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"version": "2.6.8",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
"integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
"requires": {
"whatwg-url": "^5.0.0"
}
@ -4284,9 +4284,9 @@
}
},
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"version": "2.6.8",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
"integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
"requires": {
"whatwg-url": "^5.0.0"
}
@ -22902,9 +22902,9 @@
"dev": true
},
"ua-parser-js": {
"version": "0.7.28",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz",
"integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -23370,9 +23370,9 @@
}
},
"ua-parser-js": {
"version": "0.7.28",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz",
"integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",

View File

@ -11026,8 +11026,8 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
ua-parser-js@^0.7.9:
version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
version "0.7.33"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
uglify-js@3.4.x:
version "3.4.10"

View File

@ -24786,9 +24786,9 @@
}
},
"ua-parser-js": {
"version": "0.7.24",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.24.tgz",
"integrity": "sha512-yo+miGzQx5gakzVK3QFfN0/L9uVhosXBBO7qmnk7c2iw1IhL212wfA3zbnI54B0obGwC/5NWub/iT9sReMx+Fw=="
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw=="
},
"uglify-js": {
"version": "3.4.10",