Merge branch 'dev'

This commit is contained in:
VesaJuvonen 2020-01-11 18:18:25 +02:00
commit f4854547fe
67 changed files with 28803 additions and 5330 deletions

View File

@ -1,7 +1,7 @@
# React Graph Calendar Web Part
## Summary
This is a sample web part developed using React Framework to gather events from the underlying group calendar of a Team site. This sample also demonstrates the utilization of web parts as Teams tabs and offering a visualization context to change behaviors based on the platform used (Getting the proper information from the team vs. SharePoint site, understanding the context of the theme on Teams, etc.).
This is a sample web part developed using React Framework to gather events from the underlying group calendar of a Team site. This sample also demonstrates the utilization of web parts as Teams tabs and Personal tab and offering a visualization context to change behaviors based on the platform used (Getting the proper information from the team vs. SharePoint site, understanding the context of the theme on Teams, etc.).
### Web Part in SharePoint Online
![The web part in action](./assets/react-graph-calendar-spo.gif)
@ -9,14 +9,14 @@ This is a sample web part developed using React Framework to gather events from
### Web Part in Microsoft Teams
![The web part in action](./assets/react-graph-calendar-teams.gif)
Webpart is developed using below technologies
Web part is developed using below technologies
* React Framework
* Full Calendar (fullcalendar.io)
* Microsoft Teams API
* Office UI Fabric
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/version-1.9.1-green.svg)
![drop](https://img.shields.io/badge/version-1.10-green.svg)
## Applies to
@ -38,6 +38,7 @@ react-graph-calendar | [Sébastien Levert](https://www.linkedin.com/in/sebastien
Version|Date|Comments
-------|----|--------
1.0 |December 29, 2019 | Initial Release
1.1 |January 08, 2020 | Bumped to SPFx 1.10 and added the Personal Tab support
## 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.**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 MiB

After

Width:  |  Height:  |  Size: 8.7 MiB

View File

@ -11,6 +11,10 @@
{
"resource": "Microsoft Graph",
"scope": "Group.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Calendars.Read"
}
]
},

File diff suppressed because it is too large Load Diff

View File

@ -17,10 +17,10 @@
"@fullcalendar/moment": "^4.3.0",
"@fullcalendar/moment-timezone": "^4.3.0",
"@fullcalendar/react": "^4.3.0",
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-webpart-base": "1.10.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
@ -35,10 +35,10 @@
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",

View File

@ -12,15 +12,15 @@
// 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,
"supportedHosts": ["SharePointWebPart", "TeamsTab"],
"supportedHosts": ["SharePointWebPart", "TeamsTab", "TeamsPersonalApp"],
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "GraphCalendar" },
"description": { "default": "GraphCalendar description" },
"officeFabricIconFontName": "Page",
"title": { "default": "Graph Calendar" },
"description": { "default": "Graph Calendar" },
"officeFabricIconFontName": "Calendar",
"properties": {
"description": "GraphCalendar"
"description": "Graph Calendar"
}
}]
}

View File

@ -44,15 +44,14 @@ export default class GraphCalendarWebPart extends BaseClientSideWebPart<IGraphCa
}
// Sets the Teams context if in Teams
if (this.context.microsoftTeams) {
this.context.microsoftTeams.getContext(context => {
this._teamsContext = context;
// resolve the promise
resolve(undefined);
});
if (this.context.sdks.microsoftTeams) {
this._teamsContext = this.context.sdks.microsoftTeams.context;
// Initialize the OUIF icons if in Teams
initializeIcons();
// resolve the promise
resolve(undefined);
} else {
// resolve the promise
resolve(undefined);

View File

@ -17,6 +17,12 @@ interface IGraphCalendarState {
isEventDetailsOpen: boolean;
currentSelectedEvent: EventInput;
groupId: string;
tabType: TabType;
}
enum TabType {
TeamsTab,
PersonalTab
}
export default class GraphCalendar extends React.Component<IGraphCalendarProps, IGraphCalendarState> {
@ -45,7 +51,8 @@ export default class GraphCalendar extends React.Component<IGraphCalendarProps,
currentActiveEndDate: null,
isEventDetailsOpen: false,
currentSelectedEvent: null,
groupId: this._isRunningInTeams() ? this.props.teamsContext.groupId : this.props.context.pageContext.site.group ? this.props.context.pageContext.site.group.id : ""
groupId: this._isRunningInTeams() ? this.props.teamsContext.groupId : this.props.context.pageContext.site.group ? this.props.context.pageContext.site.group.id : "",
tabType: this._isRunningInTeams() ? (this._isPersonalTab() ? TabType.PersonalTab : TabType.TeamsTab) : TabType.TeamsTab
};
}
@ -73,7 +80,7 @@ export default class GraphCalendar extends React.Component<IGraphCalendarProps,
plugins={[ dayGridPlugin ]}
windowResize={this._handleResize.bind(this)}
datesRender={this._datesRender.bind(this)}
eventClick={this._handleEventClick.bind(this)}
eventClick={this._openEventPanel.bind(this)}
height={this.state.height}
events={this.state.events} />
{this.state.currentSelectedEvent &&
@ -81,6 +88,8 @@ export default class GraphCalendar extends React.Component<IGraphCalendarProps,
isOpen={this.state.isEventDetailsOpen}
type={ PanelType.smallFixedFar }
headerText={this.state.currentSelectedEvent ? this.state.currentSelectedEvent.title : ""}
onDismiss={this._closeEventPanel.bind(this)}
isLightDismiss={true}
closeButtonAriaLabel='Close'>
<h3>Start Time</h3>
<span>{moment(this.state.currentSelectedEvent.start).format('MMMM Do YYYY [at] h:mm:ss a')}</span>
@ -123,17 +132,40 @@ export default class GraphCalendar extends React.Component<IGraphCalendarProps,
return this.props.teamsContext;
}
/**
* Validates if the current web part is running in a Personal Tab
*/
private _isPersonalTab() {
let _isPersonalTab: Boolean = false;
if(this._isRunningInTeams() && !this.props.teamsContext.teamId) {
_isPersonalTab = true;
}
return _isPersonalTab;
}
/**
* Handles the click event and opens the OUIF Panel
* @param eventClickInfo The information about the selected event
*/
private _handleEventClick(eventClickInfo: any) {
private _openEventPanel(eventClickInfo: any) {
this.setState({
isEventDetailsOpen: true,
currentSelectedEvent: eventClickInfo.event
});
}
/**
* Handles the click event on the dismiss from the Panel and closes the OUIF Panel
*/
private _closeEventPanel() {
this.setState({
isEventDetailsOpen: true,
currentSelectedEvent: null
});
}
/**
* If the view changed, reload the events based on the active view
* @param info Information about the current active view
@ -166,15 +198,20 @@ export default class GraphCalendar extends React.Component<IGraphCalendarProps,
*/
private _loadEvents(startDate: Date, endDate: Date): void {
// If a Group was found, execute the query. If not, do nothing.
if(this.state.groupId) {
// If a Group was found or running in the context of a Personal tab, execute the query. If not, do nothing.
if(this.state.groupId || this.state.tabType == TabType.PersonalTab) {
this.props.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient): void => {
let apiUrl: string = `/groups/${this.state.groupId}/events`;
if(this._isPersonalTab()) {
apiUrl = '/me/events';
}
client
.api(`/groups/${this.state.groupId}/events`)
.api(apiUrl)
.version("v1.0")
.select('subject,start,end,location,bodyPreview,isAllDay')
.filter(`start/dateTime ge '${startDate.toISOString()}' and end/dateTime le '${endDate.toISOString()}'`)

View File

@ -8,7 +8,7 @@ Sample SPFx React web part which allows sending emails using Microsoft Graph.
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/version-1.8.2-green.svg)
![drop](https://img.shields.io/badge/version-1.9.1-green.svg)
## Applies to
@ -25,7 +25,8 @@ react-graph-feedback-form|Sergei Zheleznov (CollabStack)
Version|Date|Comments
-------|----|--------
1.0|August 12, 2019|Initial release
1.0.0|August 12, 2019|Initial release
1.0.3|Dec 15, 2019|Added Logger (@pnp/logging), Added max message length property (PropertyFieldNumber control from spfx-controls-react), Code refactoring, SPFx updated to 1.9.1
## Disclaimer
@ -54,5 +55,7 @@ This sample illustrates the following concepts:
* sending e-mails using Microsoft Graph
* using MSGraphClient in a SharePoint Framework web part
* using @microsoft/microsoft-graph-types
* using @pnp/logging
* using @pnp/spfx-property-controls
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-graph-feedback-form" />

View File

@ -13,6 +13,7 @@
},
"externals": {},
"localizedResources": {
"FeedbackFormWebPartStrings": "lib/webparts/feedbackForm/loc/{locale}.js"
"FeedbackFormWebPartStrings": "lib/webparts/feedbackForm/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -3,7 +3,7 @@
"solution": {
"name": "spfx-feedback-form-client-side-solution",
"id": "8cf91ad7-be8e-4f6c-a1eb-a790f3ef5a32",
"version": "1.0.0.4",
"version": "1.0.0.5",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"webApiPermissionRequests": [

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "react-graph-feedback-form",
"version": "1.0.0",
"version": "1.0.3",
"author": {
"name": "Sergei Zheleznov",
"url": "https://collabstack.de"
@ -16,31 +16,33 @@
"test": "gulp test"
},
"dependencies": {
"react": "16.7.0",
"react-dom": "16.7.0",
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-property-pane": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@pnp/logging": "^1.3.6",
"@pnp/spfx-property-controls": "1.16.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.7.22",
"@types/react-dom": "16.8.0",
"office-ui-fabric-react": "6.143.0",
"@microsoft/sp-core-library": "1.8.2",
"@microsoft/sp-property-pane": "1.8.2",
"@microsoft/sp-webpart-base": "1.8.2",
"@microsoft/sp-lodash-subset": "1.8.2",
"@microsoft/sp-office-ui-fabric-core": "1.8.2",
"@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33"
"office-ui-fabric-react": "^6.143.0",
"react": "16.7.0",
"react-dom": "16.7.0"
},
"resolutions": {
"@types/react": "16.7.22"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^1.10.0",
"@microsoft/rush-stack-compiler-2.9": "0.7.7",
"@microsoft/sp-build-web": "1.8.2",
"@microsoft/sp-module-interfaces": "1.8.2",
"@microsoft/sp-tslint-rules": "1.8.2",
"@microsoft/sp-webpart-workbench": "1.8.2",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"@microsoft/rush-stack-compiler-2.9": "0.9.10",
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@types/chai": "4.2.7",
"@types/mocha": "5.2.7",
"ajv": "~5.2.2",
"gulp": "~3.9.1"
}

View File

@ -6,38 +6,52 @@ import {
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-property-pane';
import * as strings from 'FeedbackFormWebPartStrings';
import FeedbackForm from './components/FeedbackForm';
import { IFeedbackFormProps } from './components/IFeedbackFormProps';
import { MSGraphClient } from '@microsoft/sp-http';
// https://sharepoint.github.io/sp-dev-fx-property-controls/
import { PropertyFieldNumber } from '@pnp/spfx-property-controls/lib/PropertyFieldNumber';
import {
Logger,
ConsoleListener,
LogLevel
} from "@pnp/logging";
// https://pnp.github.io/pnpjs/logging/docs/
// https://blog.mastykarz.nl/logging-sharepoint-framework/
const LOG_SOURCE: string = 'FeedbackFormWebPart';
Logger.subscribe(new ConsoleListener());
Logger.activeLogLevel = LogLevel.Info;
export interface IFeedbackFormWebPartProps {
targetEmail: string;
subject: string;
maxMessageLength: number;
}
export default class FeedbackFormWebPart extends BaseClientSideWebPart<IFeedbackFormWebPartProps> {
private _graphClient: MSGraphClient;
public onInit(): Promise<void> {
return new Promise<void>((resolve: () => void, reject: (error: any) => void ): void => {
this.context.msGraphClientFactory
.getClient()
.then((cli: MSGraphClient): void => {
this._graphClient = cli;
resolve();
}, err => reject(err));
});
private graphClient: MSGraphClient;
public async onInit(): Promise<void> {
Logger.write(`[${LOG_SOURCE}] onInit()`);
try {
Logger.write(`[${LOG_SOURCE}] trying to retrieve graphClient`);
this.graphClient = await this.context.msGraphClientFactory.getClient();
} catch (error) {
Logger.writeJSON(error, LogLevel.Error);
}
}
public render(): void {
Logger.write(`[${LOG_SOURCE}] render()`);
const element: React.ReactElement<IFeedbackFormProps> = React.createElement(
FeedbackForm,
{
graphClient: this._graphClient,
graphClient: this.graphClient,
targetEmail: this.properties.targetEmail,
maxMessageLength: this.properties.maxMessageLength,
subject: this.properties.subject
}
);
@ -45,6 +59,7 @@ export default class FeedbackFormWebPart extends BaseClientSideWebPart<IFeedback
}
protected onDispose(): void {
Logger.write(`[${LOG_SOURCE}] onDispose()`);
ReactDom.unmountComponentAtNode(this.domElement);
}
@ -68,6 +83,14 @@ export default class FeedbackFormWebPart extends BaseClientSideWebPart<IFeedback
}),
PropertyPaneTextField('subject', {
label: strings.SubjectFieldLabel
}),
PropertyFieldNumber("maxMessageLength", {
key: "maxMessageLength",
label: "Maximum length of a message",
value: this.properties.maxMessageLength,
maxValue: 250,
minValue: 3,
disabled: false
})
]
}

View File

@ -1,9 +0,0 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.feedbackForm {
max-width: 700px;
margin: 0px auto;
.formActions {
margin: 10px 0;
}
}

View File

@ -1,20 +1,28 @@
import * as React from 'react';
import styles from './FeedbackForm.module.scss';
import { IFeedbackFormProps } from './IFeedbackFormProps';
import { escape } from '@microsoft/sp-lodash-subset';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
// https://developer.microsoft.com/en-us/fabric#/controls/web
import {
TextField,
DefaultButton,
MessageBar,
MessageBarType,
MessageBarButton
MessageBarButton,
Stack
} from 'office-ui-fabric-react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import {
Logger,
LogLevel
} from "@pnp/logging";
const LOG_SOURCE: string = 'FeedbackForm';
export interface IFeedbackFormState {
isBusy: boolean;
message: string;
messageSended: boolean;
messageWasSended: boolean;
messageText: string;
}
export default class FeedbackForm extends React.Component<IFeedbackFormProps, IFeedbackFormState> {
@ -24,26 +32,55 @@ export default class FeedbackForm extends React.Component<IFeedbackFormProps, IF
this.state = {
isBusy: false,
message: '',
messageSended: false
messageWasSended: false,
messageText: '',
};
}
public render(): React.ReactElement<IFeedbackFormProps> {
return (
<div className={ styles.feedbackForm }>
{this.props.targetEmail ? '' : (
<MessageBar messageBarType={MessageBarType.warning}>Target email is empty! Please configure this web part first.</MessageBar>
)}
{this.state.messageSended ? (
<MessageBar
actions={
<div>
<MessageBarButton onClick={()=>{
this.setState({
messageSended:false
});
}}>I want to send more!</MessageBarButton>
{this.props.targetEmail ? '' : this.notConfiguredAlert}
{this.state.messageWasSended ? this.messageBar : this.feedbackForm}
</div>
);
}
private get feedbackForm(): JSX.Element {
const { messageText, isBusy } = this.state;
const { targetEmail, maxMessageLength } = this.props;
return(
<Stack gap={5} styles={{ root: { width: 650, margin: "0 auto" } }}>
<TextField
disabled={isBusy}
label="Feedback"
maxLength={maxMessageLength}
multiline
rows={3}
value={messageText}
onChange={this.onTextFieldChangeHandler}
/>
<p>{messageText.length} of {maxMessageLength}</p>
<div>
<DefaultButton
disabled={isBusy || !targetEmail}
onClick={this.sendMessageHandler}
>Send Message</DefaultButton>
</div>
</Stack>
);
}
private get messageBar(): JSX.Element {
Logger.write(`[${LOG_SOURCE}] renderMessageBar()`);
return(
<MessageBar
actions = {
<div>
<MessageBarButton onClick={this.messageBarButtonOnClickHandler}>I want to send more!</MessageBarButton>
</div>
}
messageBarType={MessageBarType.success}
@ -51,53 +88,71 @@ export default class FeedbackForm extends React.Component<IFeedbackFormProps, IF
>
Message was sent!
</MessageBar>
) :
(
<>
<TextField disabled={this.state.isBusy} label="Feedback" multiline rows={3} name="text" value={this.state.message} onChange={this._onChange} />
<div className={ styles.formActions }>
<DefaultButton disabled={this.state.isBusy || !this.props.targetEmail} onClick={this._sendMessage}>Send</DefaultButton>
</div>
</>
)}
</div>
);
}
private _onChange = (event: React.ChangeEvent<HTMLInputElement>) : void => {
this.setState({message:event.target.value});
private get notConfiguredAlert(): JSX.Element {
Logger.write(`[${LOG_SOURCE}] renderNotConfiguredAlert()`);
return(
<MessageBar messageBarType={MessageBarType.warning}>Target email is empty! Please configure this web part first.</MessageBar>
);
}
private _sendMessage = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) : Promise<void> => {
this.setState({isBusy:true});
private onTextFieldChangeHandler = (event: React.ChangeEvent<HTMLTextAreaElement>): void => {
const messageText = event.target.value;
this.setState({messageText});
}
const msg = {
subject: escape(this.props.subject),
private sendMessageHandler = async(): Promise<void> => {
Logger.write(`[${LOG_SOURCE}] sendMessageHandler()`);
const { graphClient, targetEmail, subject } = this.props;
const { messageText } = this.state;
this.setState({ isBusy:true });
Logger.write(`[${LOG_SOURCE}] composing message`);
const message: MicrosoftGraph.Message = {
subject: escape(subject),
importance:"low",
body:{
body: {
contentType:"html",
content: escape(this.state.message)
content: escape(messageText)
},
toRecipients:[
toRecipients: [
{
emailAddress:{
address: this.props.targetEmail
emailAddress: {
address: targetEmail
}
}
]
} as MicrosoftGraph.Message;
};
let messageWasSended = false;
try {
Logger.write(`[${LOG_SOURCE}] trying send email to ${this.props.targetEmail}`);
await graphClient.api('/me/sendMail').post({message});
messageWasSended = true;
} catch (error) {
Logger.writeJSON(error, LogLevel.Error);
} finally {
await this.props.graphClient.api('/me/sendMail')
.post({
message : msg
}).then(() => {
this.setState({
isBusy:false,
message: '',
messageSended: true
messageWasSended
});
},(error: any) => {
console.log(error);
}
}
private messageBarButtonOnClickHandler = (): void => {
this.setState({
messageWasSended:false,
messageText: '',
});
}
}

View File

@ -3,5 +3,6 @@ import { MSGraphClient } from '@microsoft/sp-http';
export interface IFeedbackFormProps {
graphClient: MSGraphClient;
targetEmail: string;
maxMessageLength: number;
subject: string;
}

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
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

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.9.1",
"libraryName": "react-manage-o-365-groups",
"libraryId": "0db7eade-b32d-477c-8a16-b7646837c370",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,206 @@
## Manage Office 365 Groups with SPFx
### Summary
Office 365 Groups is the foundational membership service, that drives all teamwork across Microsoft 365. Once in a group, we can get the benefits of the group-connected services like shared Outlook inbox, shared calendar, SharePoint site, Planner, Power BI, Yammer, and Teams.
Is there a way to see all Office 365 Groups in a tenant?
*Yes, partially. You can view and manage your own groups in Outlook on the web.*
This SPFx solution goes beyond this limitation and provides below functionalities using MS Graph APIs:
1. List all public and private groups with basic information (name, description, privacy, logo, etc.)
2. Search the group
3. Join and leave public groups
4. Join a private group with approval of group administrator (using Power Automate flow) and leave the private group
5. Manage group link for administrators
6. Browse MS Teams associated with group
7. Set up new group
![WebPart Preview](./assets/web-part-preview.gif)
### NPM Packages Used
Below NPM package(s) are used to develop this sample:
1. PnP/PnPjs (https://pnp.github.io/pnpjs/)
2. @pnp/spfx-controls-react (https://sharepoint.github.io/sp-dev-fx-controls-react/)
### Project setup and important files
```txt
react-manage-o365-groups
├── Power Automate Flow <-- Power Automate Flow Templates
│ └── Logic Apps Template
│ └── Join Private Group.json
│ └── Package
│ └── JoinPrivateGroup_20191230151251.zip
└── src
└── models
├── IGroup.ts
├── ITeamChannel.ts
└── services
├── O365GroupService.ts <-- Extensible Service
└── webparts
└── o365GroupsManager
├── O365GroupsManagerWebPart.manifest.json
├── O365GroupsManagerWebPart.ts
├── components
│ └── O365GroupsManager
│ │ ├── O365GroupsManager.tsx <-- Group Management Component
│ │ ├── O365GroupsManager.module.scss
│ │ ├── IO365GroupsManagerProps.ts
│ │ ├── IO365GroupsManagerState.ts
│ └── GroupList <-- Group Listing Component
| │ ├── GroupList.tsx
| │ ├── IGroupListProps.ts
| │ ├── IGroupListState.ts
│ └── NewGroup <-- New Group Creation Component
| ├── NewGroup.tsx
| ├── INewGroupProps.ts
| ├── INewGroupState.ts
└── loc
├── en-us.js
└── mystrings.d.ts
```
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/drop-1.9.1-green.svg)
# Demo
The SPFx solution provides below functionalities:
## O365 Group Listing and Search
The web part lists all public and private O365 groups and helps to search the group by name.
![Group Listing and Search](./assets/group-listing-search.gif)
Below are the list of options available for user based on the role.
Group Visibility|Role|Action
----------------|----|------
Public|Owner|Manage Group
Public|Member|Leave Group
Public|-|Join Group
Private|Owner|Manage Group
Private|Member|Leave Group
Private|-|Request to Join Group
## Join the Public Group
Clicking "Join Group" icon against the O365 group helps to join the public group.
![Join Public Group](./assets/join-group.gif)
## Join the Private Group
Clicking "Request to Join Group" icon against the O365 group helps to send the approval request to the owners of the private group. The approval takes place using Power Automate.
![Join Private Group](./assets/join-private-group.gif)
## Leave the Group
Clicking "Leave Group" icon against the O365 group helps to leave the public and private group.
![Leave Group](./assets/leave-group.gif)
## Create New Group
The web part helps to create new O365 group.
![New Group](./assets/new-group.gif)
## Applies to
* [SharePoint Framework Developer Preview](http://dev.office.com/sharepoint/docs/spfx/sharepoint-framework-overview)
* [SharePoint Online](https://docs.microsoft.com/en-us/sharepoint/sharepoint-online)
* [Office 365 developer tenant](http://dev.office.com/sharepoint/docs/spfx/set-up-your-developer-tenant)
# WebPart Properties
Property|Type|Required|Comments
--------|----|--------|--------
Flow URL|Text|No|URL of MS Flow (Power Automate) to join the private group
# Solution
Solution|Author(s)
--------|---------
react-manage-o365-groups|[Nanddeep Nachan](https://www.linkedin.com/in/nanddeepnachan/) (SharePoint Consultant, [@NanddeepNachan](https://twitter.com/NanddeepNachan) )
&nbsp;|[Smita Nachan](https://www.linkedin.com/in/smitanachan/) (SharePoint Consultant, [@SmitaNachan](https://twitter.com/SmitaNachan) )
## Version history
Version|Date|Comments
-------|----|--------
1.0.0|January 01, 2020|Initial release
# 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.**
---
# Prerequisites
- Administrative access to Azure AD of Office 365 tenant
- Permissions to create a flow in Power Automate (prior MS Flow)
- Set up Power Automate flow for approval to join private group. Please refer [Calling Graph API from Power Automate Flow](https://www.c-sharpcorner.com/article/calling-graph-api-from-power-automate-flow/)
- SharePoint Online tenant
- Site Collection created under the **/sites/** or **/**-
- You need following set of permissions in order to manage Office 365 groups. Find out more about consuming the [Microsoft Graph API in the SharePoint Framework](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/use-aad-tutorial)
```
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "Group.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Group.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.AccessAsUser.All"
},
{
"resource": "Microsoft Graph",
"scope": "User.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "User.ReadWrite.All"
}
]
```
# Minimal Path to Awesome
- Clone this repo
- Navigate to the folder with current sample
- Restore dependencies: `$ npm i`
- Bundle the solution: `$ gulp bundle --ship`
- Package the solution: `$ gulp package-solution --ship`
- Locate the solution at `./sharepoint/solution/react-manage-o365-groups.sppkg` and upload it to SharePoint tenant app catalog
- You will see a message saying that solution has pending permissions which need to be approved:
![Pending permission requests](./assets/pending-permission-requests.png)
- Approve the permission requests. There are [different options available](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/use-aadhttpclient#manage-permission-requests) - new SharePoint Admin UI, PowerShell, [`$o365`](https://pnp.github.io/office365-cli/) cli.
- For the matter of this sample, the fastest way to do it is through new SharePoint Admin UI.
- Open Web API permission management page by navigating to the url `https://<tenant>-admin.sharepoint.com/_layouts/15/online/AdminHome.aspx#/webApiPermissionManagement` (change the `<tenant>` to your O365 tenant name) or by going to the new Admin UI directly from old SharePoint Admin Center.
- Select pending requests and approve it:
![Approve request from new Admin UI](./assets/approve-request.gif)
- Run `$ gulp serve --nobrowser`
- Open hosted workbench, i.e. `https://<tenant>.sharepoint.com/sites/<your site>/_layouts/15/workbench.aspx`
- Search and add `O365 Groups Manager` web part to see it in action
# Features
This project contains sample client-side web part built on the SharePoint Framework illustrating possibilities to manage Office 365 Groups using React and MS Graph.
This sample illustrates the following concepts on top of the SharePoint Framework:
- Using PnP/PnPjs
- Creating extensible services
- Explore MS Graph APIs for Office 365 Group
- Using the MSGraphClient in a SharePoint Framework web part
- Requesting API permissions in a SharePoint Framework package
- Communicating with the Microsoft Graph using its REST API
- Using Office UI Fabric controls for building SharePoint Framework client-side web parts
- Passing web part properties to React components
- Call MS Flow (Power Automate) flow from SharePoint Framework web part

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@ -0,0 +1,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"o-365-groups-manager-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/o365GroupsManager/O365GroupsManagerWebPart.js",
"manifest": "./src/webparts/o365GroupsManager/O365GroupsManagerWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"O365GroupsManagerWebPartStrings": "lib/webparts/o365GroupsManager/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "react-manage-o-365-groups",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,43 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-manage-o365-groups-client-side-solution",
"id": "0db7eade-b32d-477c-8a16-b7646837c370",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "Group.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Group.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.AccessAsUser.All"
},
{
"resource": "Microsoft Graph",
"scope": "User.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "User.ReadWrite.All"
}
]
},
"paths": {
"zippedPackage": "solution/react-manage-o365-groups.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

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,7 @@
'use strict';
const gulp = require('gulp');
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.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
{
"name": "react-manage-o-365-groups",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.9.1",
"@microsoft/sp-lodash-subset": "1.9.1",
"@microsoft/sp-office-ui-fabric-core": "1.9.1",
"@microsoft/sp-webpart-base": "1.9.1",
"@pnp/common": "^1.3.8",
"@pnp/logging": "^1.3.8",
"@pnp/odata": "^1.3.8",
"@pnp/sp": "^1.3.8",
"@pnp/spfx-controls-react": "^1.15.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"office-ui-fabric-react": "6.189.2",
"react": "16.8.5",
"react-dom": "16.8.5"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^1.12.0",
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"@microsoft/sp-build-web": "1.9.1",
"@microsoft/sp-module-interfaces": "1.9.1",
"@microsoft/sp-tslint-rules": "1.9.1",
"@microsoft/sp-webpart-workbench": "1.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"gulp": "~3.9.1"
}
}

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,15 @@
// Represents attributes of an O365 Group
export interface IGroup {
id: string;
displayName: string;
description?: string;
visibility?: string;
url?: string;
thumbnail?: string;
userRole?: string;
teamsConnected?: boolean;
}
export interface IGroupCollection {
value: IGroup[];
}

View File

@ -0,0 +1,7 @@
// Represents attributes of MS Teams channel
export interface ITeamChannel {
id: string;
displayName: string;
description: string;
webUrl: string;
}

View File

@ -0,0 +1,2 @@
export * from './IGroup';
export * from './ITeamChannel';

View File

@ -0,0 +1,306 @@
import { MSGraphClient, SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, HttpClientResponse, HttpClient, IHttpClientOptions } from "@microsoft/sp-http";
import * as MicrosoftGraph from "@microsoft/microsoft-graph-types";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IGroup, IGroupCollection, ITeamChannel } from "../models";
import { GraphRequest } from "@microsoft/microsoft-graph-client";
import { Group } from "@microsoft/microsoft-graph-types";
export class O365GroupService {
public context: WebPartContext;
public setup(context: WebPartContext): void {
this.context = context;
}
public getGroups(): Promise<IGroup[]> {
return new Promise<IGroup[]>((resolve, reject) => {
try {
// Prepare the output array
var o365groups: Array<IGroup> = new Array<IGroup>();
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api("/groups?$filter=groupTypes/any(c:c eq 'Unified')")
.get((error: any, groups: IGroupCollection, rawResponse: any) => {
// Map the response to the output array
groups.value.map((item: any) => {
o365groups.push({
id: item.id,
displayName: item.displayName,
description: item.description,
visibility: item.visibility,
teamsConnected: item.resourceProvisioningOptions.indexOf("Team") > -1 ? true : false
});
});
resolve(o365groups);
});
});
} catch (error) {
console.error(error);
}
});
}
public getMyMemberGroups(): Promise<IGroup[]> {
return new Promise<IGroup[]>((resolve, reject) => {
try {
// Prepare the output array
var o365groups: Array<IGroup> = new Array<IGroup>();
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api("/me/memberOf/$/microsoft.graph.group?$filter=groupTypes/any(a:a eq 'unified')")
.get((error: any, groups: IGroupCollection, rawResponse: any) => {
// Map the response to the output array
groups.value.map((item: any) => {
o365groups.push({
id: item.id,
displayName: item.displayName,
description: item.description,
visibility: item.visibility
});
});
resolve(o365groups);
});
});
} catch (error) {
console.error(error);
}
});
}
public getMyOwnerGroups(): Promise<any> {
return new Promise<any>((resolve, reject) => {
try {
// Prepare the output array
var o365groups: Array<IGroup> = new Array<IGroup>();
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api("/me/ownedObjects/$/microsoft.graph.group")
.get((error: any, groups: any, rawResponse: any) => {
// Map the response to the output array
groups.value.map((item: any) => {
o365groups.push({
id: item.id,
displayName: item.displayName,
description: item.description,
visibility: item.visibility
});
});
resolve(o365groups);
});
});
} catch (error) {
console.error(error);
}
});
}
public addMember(groupId: string): Promise<any> {
return new Promise<void>((resolve, reject) => {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`/groups/${groupId}/members/$ref`)
.post(`{ "@odata.id": "https://graph.microsoft.com/v1.0/users/${this.context.pageContext.user.loginName}" }`)
.then((addMemberResponse) => {
if (addMemberResponse == undefined) {
resolve();
}
else {
throw new Error(`Error occured while joining the Group`);
}
});
});
});
}
public getUserId(): Promise<string> {
return new Promise<string>((resolve, reject) => {
try {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`/me/id`)
.get((error: any, userId: any, rawResponse: any) => {
resolve(userId.value);
});
});
}
catch (error) {
console.error(error);
}
});
}
public removeMember(groupId: string): Promise<any> {
return new Promise<void>((resolve, reject) => {
this.getUserId().then(userId => {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`/groups/${groupId}/members/${userId}/$ref`)
.delete((error: any, response: any, rawResponse: any) => {
if (rawResponse.status === 204) {
resolve(response);
}
else {
throw new Error(`Error occured while leaving the Group`);
}
});
});
});
});
}
public requestToJoinPrivateGroup(flowUrl: string, groupId: string, groupName: string, groupUrl: string): Promise<any> {
const body: string = JSON.stringify({
'groupId': groupId,
'groupName': groupName,
'groupUrl': groupUrl,
'requestorName': this.context.pageContext.user.displayName,
'requestorEmail': this.context.pageContext.user.email
});
const requestHeaders: Headers = new Headers();
requestHeaders.append('Content-type', 'application/json');
const httpClientOptions: IHttpClientOptions = {
body: body,
headers: requestHeaders
};
return this.context.httpClient.post(
flowUrl,
HttpClient.configurations.v1,
httpClientOptions)
.then((response: HttpClientResponse): Promise<HttpClientResponse> => {
return response.json();
});
}
public getGroupLink(groups: IGroup): Promise<any> {
return new Promise<any>((resolve, reject) => {
try {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`/groups/${groups.id}/sites/root/weburl`)
.get((error: any, group: any, rawResponse: any) => {
resolve(group);
});
});
} catch (error) {
console.error(error);
}
});
}
public getGroupThumbnail(groups: IGroup): Promise<any> {
return new Promise<any>((resolve, reject) => {
try {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`/groups/${groups.id}/photos/48x48/$value`)
.responseType('blob')
.get((error: any, group: any, rawResponse: any) => {
resolve(window.URL.createObjectURL(group));
});
});
} catch (error) {
console.error(error);
}
});
}
public createGroup(groupName: string, groupDescription: string, groupVisibility: string, groupOwners: string[], groupMembers: string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
const groupRequest: Group = {
displayName: groupName,
description: groupDescription,
groupTypes: [
"Unified"
],
mailEnabled: true,
mailNickname: groupName.replace(/\s/g, ""),
securityEnabled: false,
visibility: groupVisibility
};
if (groupOwners && groupOwners.length) {
groupRequest['owners@odata.bind'] = groupOwners.map(owner => {
return `https://graph.microsoft.com/v1.0/users/${owner}`;
});
}
if (groupMembers && groupMembers.length) {
groupRequest['members@odata.bind'] = groupMembers.map(member => {
return `https://graph.microsoft.com/v1.0/users/${member}`;
});
}
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api("/groups")
.post(groupRequest)
.then((groupResponse) => {
console.log(groupResponse);
resolve();
});
});
});
}
public getTeamChannels = async (teamId): Promise<ITeamChannel[]> => {
return new Promise<ITeamChannel[]>((resolve, reject) => {
try {
this.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient) => {
client
.api(`teams/${teamId}/channels`)
.get((error: any, channelsResponse: any, rawResponse: any) => {
// // Prepare the output array
// var teamChannels: Array<ITeamChannel> = new Array<ITeamChannel>();
// // Map the response to the output array
// channelsResponse.value.map((item: any) => {
// teamChannels.push({
// id: item.id,
// displayName: item.displayName,
// description: item.description,
// webUrl: item.webUrl
// });
// });
resolve(channelsResponse.value);
});
});
} catch (error) {
console.log('Error getting channels for team ' + teamId, error);
}
});
}
}
const GroupService = new O365GroupService();
export default GroupService;

View File

@ -0,0 +1 @@
export * from './O365GroupService';

View File

@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "71856ff0-277e-4373-b2dd-cf1291a10f58",
"alias": "O365GroupsManagerWebPart",
"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,
"supportedHosts": ["SharePointWebPart"],
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "O365 Groups Manager" },
"description": { "default": "Manage O365 Groups" },
"officeFabricIconFontName": "Group",
"properties": {
"description": "O365GroupsManager"
}
}]
}

View File

@ -0,0 +1,72 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-webpart-base';
import * as strings from 'O365GroupsManagerWebPartStrings';
import O365GroupsManager from './components/O365GroupsManager/O365GroupsManager';
import { IO365GroupsManagerProps } from './components/O365GroupsManager/IO365GroupsManagerProps';
import O365GroupService from '../../services/O365GroupService';
export interface IO365GroupsManagerWebPartProps {
flowUrl: string;
}
export default class O365GroupsManagerWebPart extends BaseClientSideWebPart<IO365GroupsManagerWebPartProps> {
public render(): void {
const element: React.ReactElement<IO365GroupsManagerProps> = React.createElement(
O365GroupsManager,
{
flowUrl: this.properties.flowUrl,
context: this.context
}
);
ReactDom.render(element, this.domElement);
}
protected onInit(): Promise<void> {
return super.onInit().then(() => {
O365GroupService.setup(this.context);
});
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get disableReactivePropertyChanges(): boolean {
return true;
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('flowUrl', {
label: strings.FlowUrlLabel
})
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,263 @@
import * as React from 'react';
import styles from '../O365GroupsManager/O365GroupsManager.module.scss';
import { IGroupListProps } from './IGroupListProps';
import { IGroupListState } from './IGroupListState';
import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { Image, ImageFit } from 'office-ui-fabric-react/lib/Image';
import { IconButton, IIconProps, Stack } from 'office-ui-fabric-react';
import { TeachingBubble } from 'office-ui-fabric-react/lib/TeachingBubble';
import { DirectionalHint } from 'office-ui-fabric-react/lib/Callout';
import { List } from 'office-ui-fabric-react/lib/List';
import { ITheme, mergeStyleSets, getTheme, getFocusStyle } from 'office-ui-fabric-react/lib/Styling';
import { IGroup, IGroupCollection } from "../../../../models/IGroup";
import O365GroupService from '../../../../services/O365GroupService';
interface IGroupListClassObject {
itemCell: string;
itemImage: string;
itemContent: string;
itemName: string;
itemIndex: string;
chevron: string;
}
// Icons
const teamsIcon: IIconProps = { iconName: 'TeamsLogo' };
const joinIcon: IIconProps = { iconName: 'Subscribe' };
const leaveIcon: IIconProps = { iconName: 'Unsubscribe' };
const manageIcon: IIconProps = { iconName: 'AccountManagement' };
const requestToJoinIcon: IIconProps = { iconName: 'SecurityGroup' };
// List style
const theme: ITheme = getTheme();
const { palette, semanticColors, fonts } = theme;
const classNames: IGroupListClassObject = mergeStyleSets({
itemCell: [
getFocusStyle(theme, { inset: -1 }),
{
minHeight: 54,
padding: 10,
boxSizing: 'border-box',
borderBottom: `1px solid ${semanticColors.bodyDivider}`,
display: 'flex',
selectors: {
'&:hover': { background: palette.neutralLight }
}
}
],
itemImage: {
flexShrink: 0
},
itemContent: {
marginLeft: 10,
overflow: 'hidden',
flexGrow: 1
},
itemName: [
fonts.xLarge,
{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
],
itemIndex: {
fontSize: fonts.small.fontSize,
color: palette.neutralTertiary,
marginBottom: 10
},
chevron: {
alignSelf: 'center',
marginLeft: 10,
color: palette.neutralTertiary,
fontSize: fonts.large.fontSize,
flexShrink: 0
}
});
export default class GroupList extends React.Component<IGroupListProps, IGroupListState> {
private _originalItems: IGroup[];
private _menuButtonElement: HTMLElement;
constructor(props: IGroupListProps) {
super(props);
props.items.map(group => {
let myUserRole: string = "";
if (props.ownerGroups.indexOf(group.id) > -1) {
myUserRole = "Owner";
}
else if (props.memberGroups.indexOf(group.id) > -1) {
myUserRole = "Member";
}
group.userRole = myUserRole;
});
this._originalItems = props.items;
this.state = {
filterText: '',
isTeachingBubbleVisible: false,
groups: this._originalItems
};
this._onRenderCell = this._onRenderCell.bind(this);
this._onDismiss = this._onDismiss.bind(this);
this._getGroupLinks(this._originalItems);
}
public render(): React.ReactElement<IGroupListProps> {
const { groups = [] } = this.state;
const resultCountText = groups.length === this._originalItems.length ? '' : ` (${groups.length} of ${this._originalItems.length} shown)`;
return (
<div className={styles.groupContainer}>
<FocusZone direction={FocusZoneDirection.vertical}>
<TextField label={'Filter by name' + resultCountText} onChange={this._onFilterChanged} />
<List items={groups} onRenderCell={this._onRenderCell} />
{this.state.isTeachingBubbleVisible ? (
<div>
<TeachingBubble
calloutProps={{ directionalHint: DirectionalHint.bottomLeftEdge }}
isWide={true}
hasCloseIcon={true}
closeButtonAriaLabel="Close"
target={this._menuButtonElement}
onDismiss={this._onDismiss}
headline="Manage O365 Groups"
>
{this.state.techingBubbleMessage}
</TeachingBubble>
</div>
) : null}
</FocusZone>
</div>
);
}
public _getGroupLinks = (groups: any): void => {
groups.map(groupItem => (
O365GroupService.getGroupLink(groupItem).then(groupUrl => {
if (groupUrl !== null) {
this.setState(prevState => ({
groups: prevState.groups.map(group => group.id === groupItem.id ? { ...group, url: groupUrl.value } : group)
}));
}
})
));
this._getGroupThumbnails(groups);
}
public _getGroupThumbnails = (groups: any): void => {
groups.map(groupItem => (
O365GroupService.getGroupThumbnail(groupItem).then(grouptb => {
console.log(grouptb);
this.setState(prevState => ({
groups: prevState.groups.map(group => group.id === groupItem.id ? { ...group, thumbnail: grouptb } : group)
}));
})
));
}
private _onFilterChanged = (_: any, text: string): void => {
this.setState({
filterText: text,
groups: text ? this._originalItems.filter(item => item.displayName.toLowerCase().indexOf(text.toLowerCase()) >= 0) : this._originalItems
});
this._getGroupLinks(this.state.groups);
}
private _onRenderCell(group: IGroup, index: number | undefined): JSX.Element {
return (
<div className={classNames.itemCell} data-is-focusable={true}>
<Image className={classNames.itemImage} src={group.thumbnail} width={50} height={50} imageFit={ImageFit.cover} />
<div className={classNames.itemContent}>
<div className={classNames.itemName}>
<a href={group.url} target="_blank">{group.displayName}</a>
{
group.teamsConnected &&
<IconButton iconProps={teamsIcon} title="Open MS Teams" ariaLabel="Open MS Teams" onClick={(event) => { this._openMSTeamsClicked(group.id); }} />
}
</div>
<div className={classNames.itemIndex}>{group.visibility}</div>
<div>{group.description}</div>
</div>
{
group.userRole === "Owner" &&
<IconButton iconProps={manageIcon} title="Manage Group" ariaLabel="Manage Group" onClick={(event) => { this._manageGroupClicked(group.id); }} />
}
{
group.userRole === "Member" &&
<span className="ms-TeachingBubbleBasicExample-buttonArea" ref={menuButton => (this._menuButtonElement = menuButton!)}>
<IconButton iconProps={leaveIcon} title="Leave Group" ariaLabel="Leave Group" onClick={(event) => { this._leaveGroupClicked(group.id, group.displayName); }} />
</span>
}
{
group.visibility === "Public" && group.userRole === "" &&
<span className="ms-TeachingBubbleBasicExample-buttonArea" ref={menuButton => (this._menuButtonElement = menuButton!)}>
<IconButton iconProps={joinIcon} title="Join Group" ariaLabel="Join Group" onClick={(event) => { this._joinGroupClicked(group.id, group.displayName); }} />
</span>
}
{
group.visibility === "Private" && group.userRole === "" && this.props.flowUrl != undefined &&
<span className="ms-TeachingBubbleBasicExample-buttonArea" ref={menuButton => (this._menuButtonElement = menuButton!)}>
<IconButton iconProps={requestToJoinIcon} title="Request to Join Group" ariaLabel="Request to Join Group" onClick={(event) => { this._requestJoinGroupClicked(group.id, group.displayName, group.url); }} />
</span>
}
</div>
);
}
private _onDismiss(ev: any): void {
this.setState({
isTeachingBubbleVisible: false
});
}
private _manageGroupClicked = (groupId: string) => {
window.open("https://admin.microsoft.com/Adminportal/Home?source=applauncher#/groups");
}
private _openMSTeamsClicked = (groupId: string) => {
O365GroupService.getTeamChannels(groupId).then(response => {
window.open(response[0].webUrl, '_blank');
});
}
private _leaveGroupClicked = (groupId: string, groupName: string) => {
O365GroupService.removeMember(groupId).then(response => {
this.setState(prevState => ({
groups: prevState.groups.map(group => group.id === groupId ? { ...group, userRole: "" } : group),
isTeachingBubbleVisible: true,
techingBubbleMessage: 'You have left the group: ' + groupName
}));
});
}
private _joinGroupClicked = (groupId: string, groupName: string) => {
O365GroupService.addMember(groupId).then(response => {
this.setState(prevState => ({
groups: prevState.groups.map(group => group.id === groupId ? { ...group, userRole: "Member" } : group),
isTeachingBubbleVisible: true,
techingBubbleMessage: 'You have joined the group: ' + groupName
}));
});
}
private _requestJoinGroupClicked = (groupId: string, groupName: string, groupUrl: string) => {
this.setState({
isTeachingBubbleVisible: true,
techingBubbleMessage: 'Request sent to join the private group: ' + groupName
});
O365GroupService.requestToJoinPrivateGroup(this.props.flowUrl, groupId, groupName, groupUrl).then(response => {
console.log(response);
});
}
}

View File

@ -0,0 +1,8 @@
import { IGroup } from "../../../../models/IGroup";
export interface IGroupListProps {
flowUrl?: string;
items?: IGroup[];
ownerGroups?: string[];
memberGroups?: string[];
}

View File

@ -0,0 +1,10 @@
import { IGroup } from "../../../../models/IGroup";
export interface IGroupListState {
filterText?: string;
isTeachingBubbleVisible?: boolean;
techingBubbleMessage?: string;
groups?: IGroup[];
ownerGroups?: string[];
memberGroups?: string[];
}

View File

@ -0,0 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface INewGroupProps {
returnToMainPage: () => void;
context: WebPartContext;
}

View File

@ -0,0 +1,12 @@
import { MessageBarType } from 'office-ui-fabric-react';
export interface INewGroupState {
name: string;
description: string;
visibility: string;
owners: string[];
members: string[];
showMessageBar: boolean;
messageType?: MessageBarType;
message?: string;
}

View File

@ -0,0 +1,173 @@
import * as React from 'react';
import styles from '../O365GroupsManager/O365GroupsManager.module.scss';
import { INewGroupProps } from './INewGroupProps';
import { INewGroupState } from './INewGroupState';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup';
import { PeoplePicker, PrincipalType, IPeoplePickerUserItem } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { MessageBar, MessageBarType, IStackProps, Stack, ActionButton, IIconProps, DefaultButton } from 'office-ui-fabric-react';
import { autobind } from 'office-ui-fabric-react';
import O365GroupService from '../../../../services/O365GroupService';
const backIcon: IIconProps = { iconName: 'NavigateBack' };
const verticalStackProps: IStackProps = {
styles: { root: { overflow: 'hidden', width: '100%' } },
tokens: { childrenGap: 20 }
};
export default class NewGroup extends React.Component<INewGroupProps, INewGroupState> {
constructor(props: INewGroupProps) {
super(props);
this.state = {
name: '',
description: '',
visibility: 'Public',
owners: [],
members: [],
showMessageBar: false
};
}
public render(): React.ReactElement<INewGroupProps> {
return (
<div className={styles.o365GroupsManager}>
<div className={styles.container}>
<div className={styles.row}>
<div className={styles.headerStyle}>
<h1 className={styles.headerMsgStyle}>
<span>Add New Group</span>
<ActionButton className={styles.newHeaderLinkStyle} iconProps={backIcon} allowDisabledFocus onClick={this.props.returnToMainPage}>
Back to listing
</ActionButton>
</h1>
</div>
{
this.state.showMessageBar
?
<div className="form-group">
<Stack {...verticalStackProps}>
<MessageBar messageBarType={this.state.messageType}>{this.state.message}</MessageBar>
</Stack>
</div>
:
null
}
<div className="form-group">
<TextField label="Name" required onChanged={this.onchangedName} />
</div>
<div className="form-group">
<TextField label="Description" required multiline rows={3} onChanged={this.onchangedDescription} />
</div>
<div className="form-group">
<Label required={true}>Visibility</Label>
<ChoiceGroup
defaultSelectedKey="Public"
options={[
{
key: 'Public',
text: 'Public - Anyone can see group content'
},
{
key: 'Private',
text: 'Private - Only members can see group content'
}
]}
onChange={this.onChangeVisibility}
/>
</div>
<div className="form-group">
<PeoplePicker
context={this.props.context}
titleText="Owners"
personSelectionLimit={3}
showHiddenInUI={false}
principalTypes={[PrincipalType.User]}
selectedItems={this._getPeoplePickerOwners.bind(this)}
isRequired={true} />
</div>
<div className="form-group">
<PeoplePicker
context={this.props.context}
titleText="Members"
personSelectionLimit={3}
showHiddenInUI={false}
principalTypes={[PrincipalType.User]}
selectedItems={this._getPeoplePickerMembers}
isRequired={true} />
</div>
<div className={`${styles.buttonRow} form-group`}>
<DefaultButton text="Submit" onClick={this.createNewGroup} />
&nbsp; &nbsp;
<DefaultButton text="Cancel" onClick={this.props.returnToMainPage} />
</div>
</div>
</div>
</div>
);
}
private _getPeoplePickerOwners(items: IPeoplePickerUserItem[]) {
this.setState(() => {
return {
...this.state,
owners: items.map(x => x.id.replace('i:0#.f|membership|', ''))
};
});
}
@autobind
private _getPeoplePickerMembers(items: IPeoplePickerUserItem[]) {
this.setState(() => {
return {
...this.state,
members: items.map(x => x.id.replace('i:0#.f|membership|', ''))
};
});
}
@autobind
private onchangedName(groupName: any): void {
this.setState({ name: groupName });
}
@autobind
private onchangedDescription(groupDescription: any): void {
this.setState({ description: groupDescription });
}
@autobind
private onChangeVisibility(ev: React.FormEvent<HTMLInputElement>, option: IChoiceGroupOption): void {
this.setState({ visibility: option.key });
}
@autobind
private createNewGroup(): void {
try {
O365GroupService.createGroup(this.state.name, this.state.description, this.state.visibility, this.state.owners, this.state.members);
this.setState({
message: "Group " + this.state.name + " is created successfully!",
showMessageBar: true,
messageType: MessageBarType.success
});
} catch (error) {
this.setState({
message: "Group " + this.state.name + " creation failed with error: " + error,
showMessageBar: true,
messageType: MessageBarType.error
});
}
}
}

View File

@ -0,0 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IO365GroupsManagerProps {
flowUrl: string;
context: WebPartContext;
}

View File

@ -0,0 +1,10 @@
import { IGroup, IGroupCollection } from "../../../../models/IGroup";
export interface IO365GroupsManagerState {
isLoading: boolean;
groups: IGroup[];
ownerGroups: string[];
memberGroups: string[];
showNewGroupScreen: boolean;
loadCount: number;
}

View File

@ -0,0 +1,112 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.o365GroupsManager {
.container {
max-width: 700px;
margin: 0px auto;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.groupContainer {
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
padding-top: 5px;
padding-right: 10px;
padding-bottom: 5px;
padding-left: 10px;
position: relative;
}
.row {
@include ms-Grid-row;
// @include ms-fontColor-white;
background-color: transparent;
padding: 20px;
}
.buttonRow {
text-align: center;
padding-top: 10px;
}
.column {
@include ms-Grid-col;
@include ms-lg10;
@include ms-xl8;
@include ms-xlPush2;
@include ms-lgPush1;
}
.title {
@include ms-font-xl;
// @include ms-fontColor-white;
}
.subTitle {
@include ms-font-l;
// @include ms-fontColor-white;
}
.description {
@include ms-font-l;
// @include ms-fontColor-white;
}
.headerStyle {
display: block;
}
.headerMsgStyle {
display: inline-block;
width:100%;
}
.newHeaderLinkStyle {
display: inline;
float: right;
font-size:14px;
cursor: pointer;
}
.requiredField:after {
content:"*";
color:red;
margin-left:2px;
}
.button {
// Our button
text-decoration: none;
height: 32px;
// Primary Button
min-width: 80px;
background-color: $ms-color-themePrimary;
border-color: $ms-color-themePrimary;
color: $ms-color-white;
// Basic Button
outline: transparent;
position: relative;
font-family: "Segoe UI WestEuropean","Segoe UI",-apple-system,BlinkMacSystemFont,Roboto,"Helvetica Neue",sans-serif;
-webkit-font-smoothing: antialiased;
font-size: $ms-font-size-m;
font-weight: $ms-font-weight-regular;
border-width: 0;
text-align: center;
cursor: pointer;
display: inline-block;
padding: 0 16px;
.label {
font-weight: $ms-font-weight-semibold;
font-size: $ms-font-size-m;
height: 32px;
line-height: 32px;
margin: 0 4px;
vertical-align: top;
display: inline-block;
}
}
}

View File

@ -0,0 +1,114 @@
import * as React from 'react';
import styles from './O365GroupsManager.module.scss';
import { IO365GroupsManagerProps } from './IO365GroupsManagerProps';
import { IO365GroupsManagerState } from './IO365GroupsManagerState';
import O365GroupService from '../../../../services/O365GroupService';
import GroupList from '../GroupList/GroupList';
import NewGroup from "../NewGroup/NewGroup";
import { autobind, ActionButton, IIconProps } from 'office-ui-fabric-react';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
const addGroupIcon: IIconProps = { iconName: 'AddGroup' };
export default class O365GroupsManager extends React.Component<IO365GroupsManagerProps, IO365GroupsManagerState> {
constructor(props: IO365GroupsManagerProps) {
super(props);
this.state = {
isLoading: true,
groups: [],
ownerGroups: [],
memberGroups: [],
showNewGroupScreen: false,
loadCount: 0
};
}
public render(): React.ReactElement<IO365GroupsManagerProps> {
return (
<div className={styles.o365GroupsManager}>
<div className={styles.container}>
<div className={styles.row}>
{
this.state.loadCount === 3 && !this.state.showNewGroupScreen
?
<p>
<h1 className={styles.headerMsgStyle}>O365 Groups Manager</h1>
<GroupList flowUrl={this.props.flowUrl} items={this.state.groups} ownerGroups={this.state.ownerGroups} memberGroups={this.state.memberGroups} ></GroupList>
<br/>
<ActionButton className={styles.newHeaderLinkStyle} iconProps={addGroupIcon} allowDisabledFocus onClick={this.showNewGroupScreen}>
New Group
</ActionButton>
</p>
:
!this.state.showNewGroupScreen &&
<Spinner label="Loading Groups..." />
}
{
this.state.showNewGroupScreen &&
<div>
<div className={styles.row}>
<div className={styles.headerStyle}>
<NewGroup returnToMainPage={this.showMainScreen} context={this.props.context} />
</div>
</div>
</div>
}
</div>
</div>
</div>
);
}
@autobind
public showNewGroupScreen() {
this.setState(() => {
return {
...this.state,
showNewGroupScreen: true
};
});
}
@autobind
public showMainScreen() {
this.setState(() => {
return {
...this.state,
showNewGroupScreen: false
};
});
}
public componentDidMount(): void {
this._getGroups();
}
public _getGroups = (): void => {
O365GroupService.getMyOwnerGroups().then(groups => {
console.log(groups);
this.setState({
ownerGroups: groups.map(item => item.id),
loadCount: this.state.loadCount + 1
});
});
O365GroupService.getMyMemberGroups().then(groups => {
console.log(groups);
this.setState({
memberGroups: groups.map(item => item.id),
loadCount: this.state.loadCount + 1
});
});
O365GroupService.getGroups().then(groups => {
console.log(groups);
this.setState({
isLoading: false,
groups: groups,
loadCount: this.state.loadCount + 1
});
});
}
}

View File

@ -0,0 +1,7 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"FlowUrlLabel": "Flow URL"
}
});

View File

@ -0,0 +1,10 @@
declare interface IO365GroupsManagerWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
FlowUrlLabel: string;
}
declare module 'O365GroupsManagerWebPartStrings' {
const strings: IO365GroupsManagerWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,38 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-2.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",
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@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-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}