Merge pull request #2197 from Adam-it/add-react-webpart-fav-list-in-grid

Add react webpart with drag and drop followed sites
This commit is contained in:
Hugo Bernier 2022-01-16 12:25:44 -05:00 committed by GitHub
commit 1e86615a68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 24281 additions and 0 deletions

View File

@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# 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

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,13 @@
{
"@microsoft/generator-sharepoint": {
"plusBeta": false,
"isCreatingSolution": true,
"environment": "spo",
"version": "1.13.0",
"libraryName": "react-followed-drag-and-drop-grid",
"libraryId": "513ca7e1-f774-438a-9790-80a4a3d08c9a",
"packageManager": "npm",
"isDomainIsolated": true,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,109 @@
# Followed Drag and Drop Grid
## Summary
![image](./assets/sorting.gif)
This web part is a good example (starting point) for a solution to implement alternative view for user followed sites (or any kind of links). The web part uses Microsoft Graph so it presents how to define needed web api permissions in package-solution and use MS Graph API endpoints.
![image](./assets/mainImage.png)
how it looks on SP site
![image](./assets/appInTeams.png)
how it looks in Teams
![image](./assets/nothingToFollow.png)
when user does not have any followed sites
Another cool feature is also done using MS Graph in order to save or update the order of the links as a `.json` file in special approot folder which is kept on each individual user OneDrive. Thanks to that the web part may keep the user order of the links in one place where it may be easily used in a SharePoint page or in Teams.
![image](./assets/linkSavedInJsonFile.png)
![image](./assets/dataAsJson.png)
## Compatibility
![SPFx 1.13.0](https://img.shields.io/badge/SPFx-1.13.0-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)
## Applies to
- [SharePoint Framework](https://aka.ms/spfx)
- [Microsoft 365 tenant](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-developer-tenant)
## Prerequisites
As this solution uses Microsoft Graph to get user followed sites it is required to approve all web api permission requests in SharePoint Admin page https://YourCoolTenantNameHere-admin.sharepoint.com/_layouts/15/online/AdminHome.aspx#/webApiPermissionManagement
## Solution
Solution|Author(s)
--------|---------
react-followed-drag-and-drop-grid | [Adam Wójcik](https://github.com/Adam-it)
## Version history
Version|Date|Comments
-------|----|--------
1.0|January 09, 2022|Initial release
## Minimal Path to Awesome
- Clone this repository
- Ensure that you are at the solution folder
- in the command-line run:
- `npm install`
- `gulp serve`
- `gulp bundle --ship`
- `gulp package-solution --ship`
- Add to AppCatalog and deploy
- Approve the MS Graph API permissions in SharePoint Admin page
## Features
Description of the extension that expands upon high-level summary above.
This extension illustrates the following concepts:
- how to use Microsoft Graph api and get user followed sites or use special approot on user OneDrive
- how to save/update data in a json file special approot folder on user OneDrive as a place to keep data a use in SharePoint or Teams
- how to create a simple alternative drag and drop view for links
## References
- [Getting started with SharePoint Framework](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-developer-tenant)
- [Building for Microsoft teams](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/build-for-teams-overview)
- [Use Microsoft Graph in your solution](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis)
- [Publish SharePoint Framework applications to the Marketplace](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/publish-to-marketplace-overview)
- [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - Guidance, tooling, samples and open-source controls for your Microsoft 365 development
## 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-followed-drag-and-drop-grid%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-followed-drag-and-drop-grid) and see what the community is saying.
If you encounter any issues while 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-followed-drag-and-drop-grid&template=bug-report.yml&sample=react-followed-drag-and-drop-grid&authors=@Adam-it&title=react-followed-drag-and-drop-grid%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-followed-drag-and-drop-grid&template=question.yml&sample=react-followed-drag-and-drop-grid&authors=@Adam-it&title=react-followed-drag-and-drop-grid%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-followed-drag-and-drop-grid&template=suggestion.yml&sample=react-followed-drag-and-drop-grid&authors=@Adam-it&title=react-followed-drag-and-drop-grid%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-followed-drag-and-drop-grid" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,79 @@
[
{
"name": "pnp-sp-dev-spfx-web-parts-react-followed-drag-and-drop-grid",
"source": "pnp",
"title": "Followed Drag and Drop Grid",
"shortDescription": "This web part is a good example (starting point) for a solution to implement alternative view for user followed sites (or any kind of links). The web part uses Microsoft Graph so it presents how to define needed web api permissions in package-solution and use MS Graph API endpoints.",
"url": "https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-followed-drag-and-drop-grid",
"longDescription": [
"YOUR-SHORT-DESCRIPTION"
],
"creationDateTime": "2022-01-09",
"updateDateTime": "2022-01-09",
"products": [
"SharePoint"
],
"metadata": [
{
"key": "CLIENT-SIDE-DEV",
"value": "React"
},
{
"key": "SPFX-VERSION",
"value": "1.13.0"
}
],
"thumbnails": [
{
"type": "image",
"order": 100,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/mainImage.png",
"alt": "Web Part Preview"
},
{
"type": "image",
"order": 101,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/appInTeams.png",
"alt": "App in Teams"
},
{
"type": "image",
"order": 102,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/nothingToFollow.png",
"alt": "Nothisg to follow"
},
{
"type": "image",
"order": 103,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/sorting.gif",
"alt": "Sorting"
},
{
"type": "image",
"order": 104,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/dataAsJson.png",
"alt": "Data as JSON"
},
{
"type": "image",
"order": 105,
"url": "https://github.com/pnp/sp-dev-fx-webparts/raw/main/samples/react-followed-drag-and-drop-grid/assets/linkSavedInJsonFile.png",
"alt": "Link saved to JSON file"
}
],
"authors": [
{
"gitHubAccount": "Adam-it",
"pictureUrl": "https://github.com/Adam-it.png",
"name": "Adam Wójcik"
}
],
"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"
}
]
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 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": {
"drag-and-drop-followed-sites-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/dragAndDropFollowedSites/DragAndDropFollowedSitesWebPart.js",
"manifest": "./src/webparts/dragAndDropFollowedSites/DragAndDropFollowedSitesWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"DragAndDropFollowedSitesWebPartStrings": "lib/webparts/dragAndDropFollowedSites/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/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": "react-followed-drag-and-drop-grid",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "react-followed-drag-and-drop-grid-client-side-solution",
"id": "513ca7e1-f774-438a-9790-80a4a3d08c9a",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"developer": {
"name": "",
"websiteUrl": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": "Undefined-1.13.0"
},
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "Sites.ReadWrite.All"
}
]
},
"paths": {
"zippedPackage": "solution/react-followed-drag-and-drop-grid.sppkg"
}
}

View File

@ -0,0 +1,6 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://tenanttocheck.sharepoint.com/sites/webpartfollowedSites/_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,16 @@
'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.`);
var getTasks = build.rig.getTasks;
build.rig.getTasks = function () {
var result = getTasks.call(build.rig);
result.set('serve', result.get('serve-deprecated'));
return result;
};
build.initialize(require('gulp'));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"name": "react-followed-drag-and-drop-grid",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.13.0",
"@microsoft/sp-lodash-subset": "1.13.0",
"@microsoft/sp-office-ui-fabric-core": "1.13.0",
"@microsoft/sp-property-pane": "1.13.0",
"@microsoft/sp-webpart-base": "1.13.0",
"@pnp/spfx-controls-react": "^3.5.0",
"classnames": "^2.2.6",
"office-ui-fabric-react": "7.174.1",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-sortable-hoc": "^2.0.0"
},
"devDependencies": {
"@types/react": "16.9.51",
"@types/react-dom": "16.9.8",
"@microsoft/sp-build-web": "1.13.0",
"@microsoft/sp-tslint-rules": "1.13.0",
"@microsoft/sp-module-interfaces": "1.13.0",
"@microsoft/rush-stack-compiler-3.9": "0.4.47",
"gulp": "~4.0.2",
"ajv": "~5.2.2",
"@types/webpack-env": "1.13.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,26 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "b63d60a8-f42c-40ef-ad08-c893ea055c5c",
"alias": "DragAndDropFollowedSitesWebPart",
"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", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "Drag and drop followed sites" },
"description": { "default": "DragAndDropFollowedSites description" },
"officeFabricIconFontName": "Page",
"properties": {}
}]
}

View File

@ -0,0 +1,51 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import * as strings from 'DragAndDropFollowedSitesWebPartStrings';
import { Version } from '@microsoft/sp-core-library';
import { initializeIcons } from 'office-ui-fabric-react';
import { IPropertyPaneConfiguration } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import DragAndDropFollowedSites from './components/dragAndDropFollowedSites/DragAndDropFollowedSites';
import { IDragAndDropFollowedSitesProps } from './components/dragAndDropFollowedSites/IDragAndDropFollowedSitesProps';
import { IDragAndDropFollowedSitesWebPartProps } from './IDragAndDropFollowedSitesWebPartProps';
export default class DragAndDropFollowedSitesWebPart extends BaseClientSideWebPart<IDragAndDropFollowedSitesWebPartProps> {
public onInit(): Promise<void> {
initializeIcons();
return Promise.resolve();
}
public render(): void {
const element: React.ReactElement<IDragAndDropFollowedSitesProps> = React.createElement(
DragAndDropFollowedSites,
{
context: this.context
}
);
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
]
}
]
};
}
}

View File

@ -0,0 +1 @@
export interface IDragAndDropFollowedSitesWebPartProps {}

View File

@ -0,0 +1,99 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
@import "../../styles/Main.module.scss";
.followedSites {
.grid {
@include ms-Grid;
.row {
@include ms-Grid-row;
.columnFullWidth {
@include ms-Grid-col;
@include ms-sm12;
}
}
.title {
@include ms-font-xl;
}
.sortableContainer {
display: flex;
flex-wrap: wrap;
width: 90%;
margin: auto;
overflow: auto;
}
.isSortingActive .sortableItem label {
display: none !important;
}
.sortableItem {
display: flex;
position: relative;
flex-direction: column;
min-width: $tileSize;
min-height: $tileSize;
margin: $marginSize;
background-color: $themePrimary;
label {
position: absolute;
cursor: pointer;
top: 4px;
color: $white;
display: none;
transition: all 0.2s ease-in-out;
&.moveButton {
right: 22px;
}
}
&:hover label {
display: block;
}
}
.sortableInnerItem {
display: block;
align-items: center;
flex-direction: row;
height: $tileHeight;
padding-top: $tilePadding;
justify-content: center;
text-align: center;
color: $white;
cursor: pointer;
text-decoration: none;
i {
@include ms-font-su;
display: block;
}
span {
display: block;
&.noIcon {
margin-top: $additionalMarginWhenNoIcon;
}
}
}
.sortableItemDragging {
box-shadow: 0 0 $marginSize $neutralPrimary;
}
}
.loader {
text-align: center;
margin-top: $marginStep;
}
.hide {
display: none;
}
}

View File

@ -0,0 +1,180 @@
import * as React from 'react';
import * as strings from 'DragAndDropFollowedSitesWebPartStrings';
import { MSGraphClient } from '@microsoft/sp-http';
import { IDragAndDropFollowedSitesProps } from './IDragAndDropFollowedSitesProps';
import { IDragAndDropFollowedSitesState } from './IDragAndDropFollowedSitesState';
import FollowedSitesService from '../../services/FollowedSites/FollowedSitesService';
import MyDataService from '../../services/MyData/MyDataService';
import IMyDataServiceInput from '../../services/MyData/IMyDataServiceInput';
import styles from './DragAndDropFollowedSites.module.scss';
import sortableStyles from '../sortableList/Sortable.module.scss';
import Constants from '../../../model/Constants';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import ErrorPanel from '../errorPanel/ErrorPanel';
import NoItems from '../noItems/NoItems';
import SortableList from '../sortableList/SortableList';
import { arrayMove } from 'react-sortable-hoc';
import IAppData from '../../../model/IAppData';
import IFollowedSite from '../../../model/IFollowedSite';
export default class DragAndDropFollowedSites extends React.Component<IDragAndDropFollowedSitesProps, IDragAndDropFollowedSitesState> {
constructor(props) {
super(props);
this.state = {
followedSitesService: null,
myDataService: null,
isError: false,
isLoading: true,
sortingIsActive: false,
urls: []
};
}
public componentDidMount(): void {
this.props.context.msGraphClientFactory
.getClient()
.then((client: MSGraphClient): void => {
const followedSitesService: FollowedSitesService = new FollowedSitesService(client);
const myDataServiceInput: IMyDataServiceInput = {
mSGraphClient: client,
httpClient: this.props.context.httpClient,
appDataFolderName: Constants.appDataFolderName,
appDataJsonFileName: Constants.appDataJsonFileName
};
const myDataService: MyDataService = new MyDataService(myDataServiceInput);
this.setState({
followedSitesService,
myDataService
});
this.state.myDataService.checkIfAppDataFolderExists()
.then(appDataFolderExists => {
if (appDataFolderExists.isError) {
this.setState({
isError: true,
isLoading: false,
});
return;
}
if (!appDataFolderExists.folderExists) {
this.state.myDataService
.createAppDataFolder()
.then(folderName => {
if (folderName === null) {
this.setState({
isError: true,
isLoading: false,
});
return;
}
this.LoadData();
});
} else {
this.LoadData();
}
});
});
}
public render(): React.ReactElement<IDragAndDropFollowedSitesProps> {
const {
urls,
isLoading,
isError,
sortingIsActive } = this.state;
const isEmpty: boolean = urls.length === 0 && !isLoading;
return (
<div className={styles.followedSites}>
<div className={styles.grid}>
<div className={styles.row}>
<div className={styles.columnFullWidth}>
<Label className={styles.title}>{strings.Title}</Label>
</div>
</div>
<div className={styles.row}>
<div className={styles.columnFullWidth}>
<div className={!isLoading || isError ? styles.hide : null}>
<Spinner label={strings.Loading} ariaLive='assertive' labelPosition='right' />
</div>
<div className={!isError ? styles.hide : null}>
<ErrorPanel />
</div>
<div className={isError ? styles.hide : null}>
<div className={!isEmpty ? styles.hide : null}>
<NoItems />
</div>
<div className={sortingIsActive ? styles.isSortingActive : null} >
<SortableList
items={urls}
axis='xy'
helperClass={sortableStyles.sortableItemDragging}
onSortEnd={this.onSortEnd}
onSortStart={this.onSortStart}
useDragHandle={true} />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
private LoadData(): void {
this.state.myDataService
.getJsonAppDataFile()
.then(appData => {
if (appData === null) {
this.setState({
isError: true,
isLoading: false,
});
return;
}
this.state.followedSitesService.getMyFollowedSites().then(followedSites => {
const followedUrls: IFollowedSite[] = followedSites.value.map(element => {
return {
name: element[Constants.nameFollowedSites],
url: element[Constants.urlFollowedSites]
} as IFollowedSite;
});
followedUrls.forEach(followedItem => {
if (appData.userFollowedSites.map(item => item.url).indexOf(followedItem.url) === -1) {
appData.userFollowedSites.push(followedItem);
}
});
appData.userFollowedSites = appData.userFollowedSites.filter(followedItem => followedUrls.map(item => item.url).indexOf(followedItem.url) > -1);
this.state.myDataService.createOrUpdateJsonDataFile(appData);
this.setState({
urls: appData.userFollowedSites,
isLoading: false,
isError: false
});
});
});
}
private onSortEnd = ({ oldIndex, newIndex }): void => {
const prevItems = this.state.urls;
this.setState({
urls: arrayMove(prevItems, oldIndex, newIndex),
sortingIsActive: false
});
const appData: IAppData = { userFollowedSites: this.state.urls };
this.state.myDataService.createOrUpdateJsonDataFile(appData);
}
private onSortStart = (): void => {
this.setState({ sortingIsActive: true });
}
}

View File

@ -0,0 +1,5 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IDragAndDropFollowedSitesProps {
context: WebPartContext;
}

View File

@ -0,0 +1,12 @@
import IFollowedSite from "../../../model/IFollowedSite";
import FollowedSitesService from "../../services/FollowedSites/FollowedSitesService";
import MyDataService from "../../services/MyData/MyDataService";
export interface IDragAndDropFollowedSitesState {
followedSitesService: FollowedSitesService;
myDataService: MyDataService;
isError: boolean;
isLoading: boolean;
sortingIsActive: boolean;
urls: IFollowedSite[];
}

View File

@ -0,0 +1,39 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
@import "../../styles/Main.module.scss";
.panel {
@include ms-font-xl;
margin-top: $marginStep;
margin-left: auto;
margin-right: auto;
width: 80%;
text-align: center;
padding: 20px 10px 20px 10px;
background-color: $neutralLight;
color: $neutralSecondary;
i {
@include ms-font-su;
}
.refreshButtonRow {
text-align: right;
max-width: 80%;
margin-left: auto;
margin-right: auto;
padding-right: $marginStep;
margin-bottom: $marginStep;
margin-top: $marginStep;
}
.console {
background-color: $black;
color: $white;
max-width: 80%;
margin-left: auto;
margin-right: auto;
@include ms-font-s;
text-align: left;
padding: 5px 10px 5px 10px;
}
}

View File

@ -0,0 +1,35 @@
import * as React from 'react';
import * as strings from 'DragAndDropFollowedSitesWebPartStrings';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import IErrorPanelProps from './IErrorPanelProps';
import IErrorPanelState from './IErrorPanelState';
import styles from './ErrorPanel.module.scss';
export default class ErrorPanel extends React.Component<IErrorPanelProps, IErrorPanelState> {
public render() {
return (
<div className={styles.panel}>
<div>
<Icon iconName={'BugSolid'} />
</div>
<div>
<label>{strings.ErrorText}</label>
</div>
<div className={styles.refreshButtonRow}>
<PrimaryButton onClick={() => this.refresh()}>
{strings.ErrorPanelRefresh}
</PrimaryButton>
</div>
<div className={styles.console}>
<p>{strings.ErrorCouldNotGetData}</p>
</div>
</div>
);
}
private refresh(): void {
window.location.reload();
}
}

View File

@ -0,0 +1 @@
export default interface IErrorPanelProps { }

View File

@ -0,0 +1 @@
export default interface IErrorPanelState { }

View File

@ -0,0 +1 @@
export default interface INoItemsProps { }

View File

@ -0,0 +1 @@
export default interface INoItemsState { }

View File

@ -0,0 +1,14 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
@import "../../styles/Main.module.scss";
.panel {
@include ms-font-xl;
margin-top: $marginStep;
margin-left: auto;
margin-right: auto;
width: 80%;
text-align: center;
padding: 20px 10px 20px 10px;
background-color: $neutralLight;
color: $neutralSecondary;
}

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import * as strings from 'DragAndDropFollowedSitesWebPartStrings';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import INoItemsProps from './INoItemsProps';
import INoItemsState from './INoItemsState';
import styles from './NoItems.module.scss';
export default class NoItems extends React.Component<INoItemsProps, INoItemsState> {
public render() {
return (
<div className={styles.panel}>
<div>
<Icon iconName={'Sad'} />
</div>
<div>
<label>{strings.NoItemsText}</label>
</div>
</div>
);
}
}

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { SortableElement, SortableHandle } from 'react-sortable-hoc';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import styles from '../sortableList/Sortable.module.scss';
export default SortableElement(({ item }) => {
const DragHandle = SortableHandle(() =>
<label className={styles.moveButton}>
<Icon iconName={'Move'} />
</label>);
const tileItem = item;
let displayName = tileItem.name;
if (displayName.length >= 15) {
displayName = `${displayName.substring(0, 13)}...`;
}
return (
<div className={styles.sortableItem}>
<DragHandle />
<a className={styles.sortableInnerItem} href={tileItem.url}>
<span>{displayName}</span>
</a>
</div>
);
});

View File

@ -0,0 +1,71 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
@import "../../styles/Main.module.scss";
.sortableContainer {
display: flex;
flex-wrap: wrap;
width: 90%;
margin: auto;
overflow: auto;
}
.isSortingActive .sortableItem label {
display: none !important;
}
.sortableItem {
display: flex;
position: relative;
flex-direction: column;
min-width: $tileSize;
min-height: $tileSize;
margin: $marginSize;
background-color: $themePrimary;
label {
position: absolute;
cursor: pointer;
top: 4px;
color: $white;
display: none;
transition: all 0.2s ease-in-out;
&.moveButton {
right: 4px;
}
}
&:hover label {
display: block;
}
}
.sortableInnerItem {
display: block;
align-items: center;
flex-direction: row;
height: $tileHeight;
padding-top: $tilePadding;
justify-content: center;
text-align: center;
color: $white;
cursor: pointer;
text-decoration: none;
i {
@include ms-font-su;
display: block;
}
span {
display: block;
&.noIcon {
margin-top: $additionalMarginWhenNoIcon;
}
}
}
.sortableItemDragging {
box-shadow: 0 0 $marginSize $neutralPrimary;
}

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import { SortableContainer } from 'react-sortable-hoc';
import styles from './Sortable.module.scss';
import SortableItem from '../sortableItem/SortableItem';
export default SortableContainer(({ items }) => (
<div className={styles.sortableContainer}>
{items.map((item, index) => (
<SortableItem
key={index}
index={index}
item={item}
/>
))}
</div>
));

View File

@ -0,0 +1,11 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"Title": "Followed Sites",
"Loading": "Loading...",
"ErrorText": "There seems to be some error. Please try to refresh the site.",
"ErrorPanelRefresh": "Refresh",
"ErrorCouldNotGetData": "> Error 🐞 - Could not get user data",
"NoItemsText": "It seems you don't follow any sites 🧐"
}
});

View File

@ -0,0 +1,14 @@
declare interface IDragAndDropFollowedSitesWebPartStrings {
PropertyPaneDescription: string;
Title: string;
Loading: string;
ErrorText: string;
ErrorPanelRefresh: string;
ErrorCouldNotGetData: string;
NoItemsText: string;
}
declare module 'DragAndDropFollowedSitesWebPartStrings' {
const strings: IDragAndDropFollowedSitesWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,24 @@
import { MSGraphClient } from '@microsoft/sp-http';
export default class FollowedSitesService {
private graphClient: MSGraphClient = null;
constructor(graphClient: MSGraphClient) {
this.graphClient = graphClient;
}
public async getMyFollowedSites(): Promise<any> {
return new Promise<any>((resolve, reject) =>
this.graphClient
.api('/me/followedSites?$top=1000')
.version('v1.0')
.get((error, response: any, rawResponse?: any) => {
if (error) {
resolve(error);
}
resolve(response);
}));
}
}

View File

@ -0,0 +1,8 @@
import { MSGraphClient, HttpClient } from '@microsoft/sp-http';
export default interface IMyDataServiceInput {
mSGraphClient: MSGraphClient;
httpClient: HttpClient;
appDataFolderName: string;
appDataJsonFileName: string;
}

View File

@ -0,0 +1,109 @@
import {
HttpClient,
HttpClientResponse
} from '@microsoft/sp-http';
import IAppData from '../../../model/IAppData';
import IAppDataFolderExistsOutput from '../../../model/IAppDataFolderExistsOutput';
import IMyDataServiceInput from "./IMyDataServiceInput";
export default class MyDataService {
private input: IMyDataServiceInput = null;
constructor(input: IMyDataServiceInput) {
this.input = input;
}
public async getAppDataFolder(): Promise<any> {
return new Promise<any>((resolve, reject) =>
this.input.mSGraphClient
.api(`/me/drive/special/approot/children?$filter=name eq '${this.input.appDataFolderName}'`)
.version('v1.0')
.get((error, response: any, rawResponse?: any) => {
if (error) {
resolve(error);
}
resolve(response);
}));
}
public async checkIfAppDataFolderExists(): Promise<IAppDataFolderExistsOutput> {
return new Promise<IAppDataFolderExistsOutput>((resolve, reject) => {
resolve(this.getAppDataFolder().then(result => {
const isError = result.errorCode !== undefined;
return {
isError,
errorMessage: isError ? result.errorMessage : '',
folderExists: !isError ? result.value.some(item => item.name === this.input.appDataFolderName) : false
} as IAppDataFolderExistsOutput;
}));
});
}
public async createAppDataFolder(): Promise<string> {
const driveItem = {
name: this.input.appDataFolderName,
folder: {},
'@microsoft.graph.conflictBehavior': 'fail'
};
return new Promise<string>((resolve, reject) =>
this.input.mSGraphClient
.api('/me/drive/special/approot/children')
.version('v1.0')
.post(driveItem)
.then(result => {
if (result != null) {
resolve(result.name.toString());
}
}));
}
public createOrUpdateJsonDataFile(appData: IAppData): void {
this.getAppDataFolder()
.then(response => {
const id = response.value.filter(item => item.name === this.input.appDataFolderName)[0].id;
const stream = JSON.stringify(appData);
this.input.mSGraphClient
.api(`/me/drive/items/${id}:/${this.input.appDataJsonFileName}:/content`)
.version('v1.0')
.put(stream);
});
}
public async getJsonAppDataFile(): Promise<IAppData> {
return new Promise<IAppData>((resolve, reject) =>
this.input.mSGraphClient
.api(`/me/drive/special/approot:/${this.input.appDataFolderName}:/children?$filter=name eq '${this.input.appDataJsonFileName}'`)
.version('v1.0')
.get((error, response: any, rawResponse?: any) => {
if (error) {
return;
}
if (response.value.length === 0) {
resolve(
{
userFollowedSites: []
} as IAppData);
}
const downloadUrl = response.value.filter(item => item.name === this.input.appDataJsonFileName)[0]['@microsoft.graph.downloadUrl'];
this.input.httpClient
.get(downloadUrl, HttpClient.configurations.v1)
.then((innerResponse: HttpClientResponse): Promise<string> => {
if (innerResponse.ok) {
return innerResponse.text();
}
return Promise.reject(innerResponse.statusText);
})
.then((settingsString: string) => {
const settings: IAppData = JSON.parse(settingsString);
resolve(settings);
});
}));
}
}

View File

@ -0,0 +1,19 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
// colors
$themePrimary: "[theme: themePrimary, default: #0078d7]";
$neutralPrimary: "[theme: neutralPrimary, default: #333]";
$neutralLight: "[theme: neutralLight, default: #eaeaea]";
$neutralSecondary: "[theme: neutralSecondary, default: #666666]";
$white: "[theme: white, default: #fff]";
$black: "[theme: black, default: #000000]";
// general styling
$marginSize: 9.4px;
$marginStep: 20px;
// tiles -> tilePadding + tileHeight = tileSize
$tileSize: 130px;
$tilePadding: 35px;
$tileHeight: 95px;
$additionalMarginWhenNoIcon: 42px;

View File

@ -0,0 +1,6 @@
export default class Constants {
public static appDataFolderName: string = 'followedSitesData';
public static appDataJsonFileName: string = 'followedSitesSavedData.json';
public static urlFollowedSites: string = 'webUrl';
public static nameFollowedSites: string = 'displayName';
}

View File

@ -0,0 +1,5 @@
import IFollowedSite from "./IFollowedSite";
export default interface IAppData {
userFollowedSites: IFollowedSite[];
}

View File

@ -0,0 +1,5 @@
export default interface IAppDataFolderExistsOutput {
isError: boolean;
errorMessage: string;
folderExists: boolean;
}

View File

@ -0,0 +1,4 @@
export default interface IFollowedSite {
name: string;
url: string;
}

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,35 @@
{
"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",
"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
}
}