Merge pull request #1366 from petkir/petkir-IE11Support-1288

This commit is contained in:
Hugo Bernier 2020-07-02 22:54:08 -04:00 committed by GitHub
commit d383a4dde8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 13335 additions and 3704 deletions

View File

@ -2,11 +2,11 @@
"@microsoft/generator-sharepoint": { "@microsoft/generator-sharepoint": {
"isCreatingSolution": true, "isCreatingSolution": true,
"environment": "spo", "environment": "spo",
"version": "1.8.2", "version": "1.10.0",
"libraryName": "react-kanban-board", "libraryName": "react-kanban-board",
"libraryId": "cccbd72b-7b89-4128-9348-0a4850ded8fd", "libraryId": "cccbd72b-7b89-4128-9348-0a4850ded8fd",
"packageManager": "npm", "packageManager": "npm",
"isDomainIsolated": false, "isDomainIsolated": false,
"componentType": "webpart" "componentType": "webpart"
} }
} }

View File

@ -1,15 +1,32 @@
---
page_type: sample
products:
- office-sp
languages:
- javascript
- typescript
extensions:
contentType: samples
technologies:
- SharePoint Framework
platforms:
- React
createdDate: 7/17/2019 12:00:00 AM
---
# React Kanban Board Webpart # React Kanban Board Webpart
## Summary ## Summary
This solution contains an SPFx webpart which shows a Kanban board using jqxKanban ReactJS component (from [JQWidgets](https://www.jqwidgets.com/jquery-widgets-documentation/documentation/jqxkanban/jquery-kanban-getting-started.htm?search=kanban)).
This solution contains an SPFx webpart that shows a kanban board using Office UI Fabric components ([Office UI Fabric](https://developer.microsoft.com/fluentui/)).
The webpart uses the default columns of the SharePoint Tasks list for showing the board's columns and the tasks. The webpart uses the default columns of the SharePoint Tasks list for showing the board's columns and the tasks.
![picture of the web part in action](assets/kanban-board.gif) ![picture of the web part in action](assets/kanbanofficeUI.gif)
## Used SharePoint Framework Version ## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/version-1.8.2-green.svg) ![1.10.0](https://img.shields.io/badge/version-1.10.0-green.svg)
## Applies to ## Applies to
@ -26,7 +43,7 @@ This webpart reads the information from a Tasks list and uses the following OOB
* Priority * Priority
* Task Status * Task Status
The Task list can be chosen using the webpart properties The Task list can be chosen using the webpart properties (BaseTemplate 171 or 107)
## Solution ## Solution
@ -34,6 +51,7 @@ Solution|Author(s)
--------|--------- --------|---------
react-kanban-board | [Ram](https://twitter.com/ram_meenavalli) react-kanban-board | [Ram](https://twitter.com/ram_meenavalli)
react-kanban-board | Daniel Westerdale ([Westerdale Solutions Ltd.](https://westerdale.blog), [@westerdaled](https://twitter.com/westerdaled?s=20)) react-kanban-board | Daniel Westerdale ([Westerdale Solutions Ltd.](https://westerdale.blog), [@westerdaled](https://twitter.com/westerdaled?s=20))
react-kanban-board | Peter Paul Kirschner ([@petkir_at](https://twitter.com/petkir_at))
## Version history ## Version history
@ -41,12 +59,31 @@ Version|Date|Comments
-------|----|-------- -------|----|--------
1.0.0.0|July 17, 2019|Initial release 1.0.0.0|July 17, 2019|Initial release
1.0.1.0|April 21, 2020|Added support for Teams hosts 1.0.1.0|April 21, 2020|Added support for Teams hosts
2.0.0.0|July 10, 2020| jqwidgets replaced with a custom Kanban Board based on Office UI Component and IE11 Support
[Read More about the implementation of this Board](./src/kanban/README.md)
## Usage
* PNP Placeholder control if not Configured
* PNP WebpartTitle control (toggle Show/Hide in property pane)
* PNP OrderPropertyPane control (change position of buckets)
* PNP ListSelectionPropertyPane control (including filtering on BaseTemplateId)
* Usage of BucketEdit in Pane (Use a component in property pane (custom field))
* Office UI Fabric
* PNP JS DataConnection to SharePoint
<!---Thanks from @petkir to: -->
<!--- -->
<!---* [Daniel Westerdale](https://github.com/westerdaled) for Testing and inspiration (everytime again)-->
<!---* [Hugo Bernier](https://github.com/hugoabernier) for Inspiration to use Office UI Fabric -->
<!---* [Jean-Philippe CIVADE](https://github.com/ewidance) for Bug Report IE11 (initiator of rewrite of this sample)-->
<!---* [RamPrasadMeenavalli](https://github.com/RamPrasadMeenavalli) for the initial Idea-->
## Disclaimer ## 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.** **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.**
**THIS WEBPART USES jQWidgets FOR SHOWING THE KANBAN BOARD. jQWidgets IS FREE TO USE UNDER THE CREATIVE COMMONS ATTRIBUTION-NONCOMMERCIAL 3.0 LICENSE. FOR COMMERCIAL USE, PLEASE CHECK THE [LICENSING TERMS](https://www.jqwidgets.com/license/) FOR jQWidgets**
--- ---
@ -60,8 +97,8 @@ Version|Date|Comments
## Features ## Features
This sample highlights the following concepts This sample highlights the following concepts
* Binding SharePoint list data to jqxKanban React Component * Binding SharePoint list data to a custom Kanban-Control
* Updating SharePoint List Items based on events from the jqxKanban board * Updating SharePoint List Items based on events from the custom Kanban-Control
When a task is moved to different columns in the Kanban Board, the status of the respective SharePoint list item is updated using PnP JS When a task is moved to different columns in the Kanban Board, the status of the respective SharePoint list item is updated using PnP JS

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -13,6 +13,9 @@
}, },
"externals": {}, "externals": {},
"localizedResources": { "localizedResources": {
"KanbanBoardWebPartStrings": "lib/webparts/kanbanBoard/loc/{locale}.js" "KanbanBoardStrings": "lib/kanban/loc/{locale}.js",
"KanbanBoardWebPartStrings": "lib/webparts/kanbanBoard/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
} }
} }

View File

@ -1,14 +1,14 @@
{ {
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json", "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": { "solution": {
"name": "react-kanban-board-client-side-solution", "name": "react-kanban-board-client-side-solution",
"id": "cccbd72b-7b89-4128-9348-0a4850ded8fd", "id": "cccbd72b-7b89-4128-9348-0a4850ded8fd",
"version": "1.0.0.0", "version": "2.0.0.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true, "skipFeatureDeployment": true,
"isDomainIsolated": false "isDomainIsolated": false
}, },
"paths": { "paths": {
"zippedPackage": "solution/react-kanban-board.sppkg" "zippedPackage": "solution/react-kanban-board.sppkg"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
{ {
"name": "react-kanban-board", "name": "react-kanban-board",
"version": "1.0.1", "main": "lib/index.js",
"version": "2.0.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -11,36 +12,41 @@
"test": "gulp test" "test": "gulp test"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-core-library": "1.8.2", "@microsoft/sp-core-library": "1.10.0",
"@microsoft/sp-lodash-subset": "1.8.2", "@microsoft/sp-lodash-subset": "1.10.0",
"@microsoft/sp-office-ui-fabric-core": "1.8.2", "@microsoft/sp-office-ui-fabric-core": "1.10.0",
"@microsoft/sp-property-pane": "1.8.2", "@microsoft/sp-property-pane": "1.10.0",
"@microsoft/sp-webpart-base": "1.8.2", "@microsoft/sp-webpart-base": "1.10.0",
"@pnp/common": "^1.3.3", "@pnp/common": "^1.3.3",
"@pnp/logging": "^1.3.3", "@pnp/logging": "^1.3.3",
"@pnp/odata": "^1.3.3", "@pnp/odata": "^1.3.3",
"@pnp/polyfill-ie11": "^2.0.2",
"@pnp/sp": "^1.3.3", "@pnp/sp": "^1.3.3",
"@pnp/spfx-controls-react": "1.19.0",
"@pnp/spfx-property-controls": "1.19.0",
"@types/es6-promise": "0.0.33", "@types/es6-promise": "0.0.33",
"@types/react": "16.7.22", "@types/react": "16.8.8",
"@types/react-dom": "16.8.0", "@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1", "@types/webpack-env": "1.13.1",
"jqwidgets-scripts": "^8.1.2", "classnames": "^2.2.6",
"office-ui-fabric-react": "^6.143.0", "office-ui-fabric-react": "6.214.0",
"react": "16.7.0", "react": "16.8.5",
"react-dom": "16.7.0" "react-dom": "16.8.5"
}, },
"resolutions": { "resolutions": {
"@types/react": "16.7.22" "@types/react": "16.8.8"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/rush-stack-compiler-2.9": "0.7.7", "@microsoft/rush-stack-compiler-2.9": "0.7.7",
"@microsoft/sp-build-web": "1.8.2", "@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-module-interfaces": "1.8.2", "@microsoft/sp-build-web": "1.10.0",
"@microsoft/sp-tslint-rules": "1.8.2", "@microsoft/sp-module-interfaces": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.8.2", "@microsoft/sp-tslint-rules": "1.10.0",
"@microsoft/sp-webpart-workbench": "1.10.0",
"@types/chai": "3.4.34", "@types/chai": "3.4.34",
"@types/mocha": "2.2.38", "@types/mocha": "2.2.38",
"ajv": "~5.2.2", "ajv": "~5.2.2",
"autoprefixer": "^9.8.4",
"gulp": "~3.9.1", "gulp": "~3.9.1",
"react-html-parser": "^2.0.2" "react-html-parser": "^2.0.2"
} }

View File

@ -0,0 +1,13 @@
import {IKanbanTask} from './IKanbanTask';
import { IKanbanBucket } from './IKanbanBucket';
export interface IKanbanBoardRenderers{
task?: (task:IKanbanTask) => JSX.Element ;
bucketHeadline?: (bucket:IKanbanBucket) => JSX.Element ;
taskDetail?: (task:IKanbanTask) => JSX.Element ;
/*
its an action not a renderer
taskEdit?: (task:IKanbanTask) => JSX.Element ;
taskAdd?: (bucket?:IKanbanBucket) => JSX.Element ;
*/
}

View File

@ -0,0 +1,18 @@
import { IKanbanBucket } from "./IKanbanBucket";
import { IKanbanTask } from "./IKanbanTask";
export interface IKanbanBoardTaskActions {
toggleCompleted?: (taskId: string) => void;
allowMove?: (taskId: string, prevBucket: IKanbanBucket, targetBucket: IKanbanBucket) => boolean;
moved?: (taskId: string, targetBucket: IKanbanBucket) => void;
/* think about Await???
*/
addTaskSaved?: (task: IKanbanTask) => void;
editTaskSaved?: (task: IKanbanTask) => void;
//deleteTask?: (task: IKanbanTask) => void;
taskEdit?: (task:IKanbanTask) => void ;
taskAdd?: (bucket?:IKanbanBucket) => void ;
}

View File

@ -0,0 +1,5 @@
export interface IKanbanBoardTaskSettings {
showPriority: boolean;
showAssignedTo: boolean;
showTaskDetailsButton: boolean;
}

View File

@ -0,0 +1,8 @@
export interface IKanbanBucket {
bucket:string;
bucketheadline:string;
percentageComplete: number;
color?:string;
allowAddTask?:boolean;
showPercentageHeadline?:boolean;
}

View File

@ -0,0 +1,35 @@
import { IPersonaProps } from "office-ui-fabric-react/lib/Persona";
export interface IKanbanTask {
taskId: string;
title: string;
isCompleted?: boolean;
assignedTo?: IPersonaProps;
htmlDescription?:string;
priority?:string;
bucket: string;
mamagedProperties?: IKanbanTaskManagedProps[];
}
export interface IKanbanTaskManagedProps {
name: string;
displayName?: string;
type: KanbanTaskMamagedPropertyType;
value: string | number | IPersonaProps | IPersonaProps[] | any;
renderer?: (name: string, value: object, type: KanbanTaskMamagedPropertyType) => JSX.Element;
}
/* 0 is bad because
const value = EnumType.xyz // = 0
if(value) {is false}
*/
export enum KanbanTaskMamagedPropertyType {
string = 1,
number = 2,
percent = 3,
html = 4,
person = 5,
persons = 6,
complex = 7
}

View File

@ -0,0 +1,50 @@
.bucket {
width: 100%;
// border-right: 1px solid gray;
.headline {
padding: 2px 10px;
line-height: 2em;
font-weight: 700;
display: block;
position: relative;
text-align: center;
border-bottom: 1px solid gray;
.headlineText{
width: calc( 100% - 10px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.colorindicator {
width: 10px;
height: 100%;
position: absolute;
top: 0;
right: 0px;
}
.processIndicatorHeight {
height: 18px;
}
}
.taskArea {
padding: 5px;
}
.placeholder {
position: relative;
}
.taskplaceholder {
position: relative;
background-color: transparent;
cursor: move;
}
/*
.placeholder::after {
content:'x';
position:absolute;
top:0;
width:100%;
height:100%;
background-color:blue;
}
*/
}

View File

@ -0,0 +1,104 @@
import * as React from 'react';
import styles from './KanbanBucket.module.scss';
import { IKanbanBucket } from './IKanbanBucket';
import { IKanbanTask } from './IKanbanTask';
import { IKanbanBoardTaskSettings } from './IKanbanBoardTaskSettings';
import { IKanbanBoardTaskActions } from './IKanbanBoardTaskActions';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { ActionButton } from 'office-ui-fabric-react';
import KanbanTask from './KanbanTask';
import classNames from 'classnames';
import * as strings from 'KanbanBoardStrings';
export interface IKanbanBucketProps extends IKanbanBucket {
buckettasks: IKanbanTask[];
tasksettings: IKanbanBoardTaskSettings;
toggleCompleted?: (taskId: string) => void;
addTask?: (bucket: string) => void;
onDragStart: (event, taskId: string, bucket: string) => void;
onDragEnd: (event, taskId: string, bucket: string) => void;
leavingTaskId?: string;
leavingBucket?: string;
overBucket?: string;
hasOneProcessIndicator: boolean;
openDetails?: (taskId: string) => void;
}
export interface IKanbanBucketState { }
export default class KanbanBucket extends React.Component<IKanbanBucketProps, IKanbanBucketState> {
constructor(props: IKanbanBucketProps) {
super(props);
this.state = {
};
}
/*
nice to use a object merge
ECMAScript 2018 Standard Method
{...t, ...tasksettings, ...taskactions}
hope this will be translated in IE
*/
public render(): React.ReactElement<IKanbanBucketProps> {
const { bucket, bucketheadline, color, buckettasks,
tasksettings, percentageComplete,
allowAddTask, showPercentageHeadline, leavingTaskId, leavingBucket,hasOneProcessIndicator } = this.props;
return (
<div
className={styles.bucket}
key={bucket}>
<div className={styles.headline}>
<div className={styles.headlineText}>{bucketheadline}</div>
{color && <div style={{ backgroundColor: color }} className={styles.colorindicator}></div>}
{showPercentageHeadline ?
(<ProgressIndicator percentComplete={percentageComplete / 100} />):
(hasOneProcessIndicator?(<div className={styles.processIndicatorHeight}></div>):(<div></div>))}
</div>
{allowAddTask && (<ActionButton
iconProps={{ iconName: 'Add' }}
allowDisabledFocus={true}
onClick={() => this.props.addTask(bucket)}
>
{strings.AddTask}
</ActionButton>)}
<div className={styles.taskArea}>
{
buckettasks.map((t) => {
const merge = { ...t, ...tasksettings, };
const isMoving = (t.taskId === leavingTaskId && t.bucket === leavingBucket);
return (<div
className={styles.taskplaceholder + (isMoving ? styles.placeholder : '')}
key={'' + t.taskId} >
<KanbanTask
key={'task' + t.taskId}
{...merge}
toggleCompleted={this.props.toggleCompleted}
isMoving={isMoving}
openDetails={this.props.openDetails}
onDragStart={(event) => this.props.onDragStart(event, t.taskId, t.bucket)}
onDragEnd={(event) => this.props.onDragEnd(event, t.taskId, t.bucket)}
/></div>
);
})
}
</div>
</div >
);
}
}

View File

@ -0,0 +1,162 @@
import * as React from 'react';
//import styles from './KanbanBucketConfigurator.module.scss';
import * as strings from 'KanbanBoardStrings';
import { TextField, MaskedTextField } from 'office-ui-fabric-react/lib/TextField';
import { Stack, IStackProps, IStackStyles } from 'office-ui-fabric-react/lib/Stack';
import { Slider } from 'office-ui-fabric-react/lib/Slider';
import { Toggle } from 'office-ui-fabric-react/lib/Toggle';
import { cloneDeep, clone, isEqual } from '@microsoft/sp-lodash-subset';
import {
ColorPicker,
ChoiceGroup,
IChoiceGroupOption,
getColorFromString,
IColor,
IColorPickerStyles,
IColorPickerProps,
PrimaryButton,
DefaultButton,
ThemeSettingName,
} from 'office-ui-fabric-react/lib/index';
import { IKanbanBucket } from './IKanbanBucket';
export interface IKanbanBucketConfiguratorProps {
index: number;
bucket: IKanbanBucket;
update: (index: number, value: IKanbanBucket) => void;
}
export interface IKanbanBucketConfiguratorState {
bucket?: IKanbanBucket;
// showHeadline: boolean;
useColor: boolean;
}
export class KanbanBucketConfigurator extends React.Component<IKanbanBucketConfiguratorProps, IKanbanBucketConfiguratorState> {
constructor(props: IKanbanBucketConfiguratorProps) {
super(props);
this.state = {
// showHeadline: false,
useColor: false
};
}
public componentDidMount(): void {
this.resetState();
}
public componentDidUpdate(prevProps: IKanbanBucketConfiguratorProps): void {
if (prevProps.bucket && this.props.bucket && !isEqual(this.props.bucket, prevProps.bucket)) {
this.resetState();
}
}
public render(): React.ReactElement<IKanbanBucketConfiguratorProps> {
/*
const columnProps: Partial<IStackProps> = {
gap: 15,
styles: { root: { width: 300 } },
};*/
/*
const colorPickerStyles: Partial<IColorPickerStyles> = {
panel: { padding: 12 },
root: {
maxWidth: 352,
minWidth: 352,
},
colorRectangle: { height: 268 },
};*/
const statebucket = this.state.bucket;
if (!statebucket) {
return (<div></div>);
}
return (
<Stack>
<TextField label={strings.BucketConfigInternalName} disabled defaultValue={statebucket.bucket} />
{/* <Toggle label={strings.BucketConfigUseCustomHeadline} onText="On" offText="Off" inlineLabel
checked={this.state.showHeadline}
onChange={(ev, checked) => { this.setState({ showHeadline: checked }); }} />
*/}
<TextField label={strings.BucketConfigHeadline} defaultValue={statebucket.bucketheadline}
onChange={(ev, value?: string) => {
const bucket = clone(this.state.bucket);
bucket.bucketheadline = value;
this.setState({ bucket: bucket });
}}
/>
<Toggle label={strings.BucketConfigShowPercentage} onText={strings.BucketConfigShowPercentageShow} offText={strings.BucketConfigShowPercentageHide} inlineLabel
checked={statebucket.showPercentageHeadline}
onChange={(ev, checked) => {
const bucket = clone(this.state.bucket);
bucket.showPercentageHeadline = checked;
this.setState({ bucket: bucket });
}} />
{statebucket.showPercentageHeadline && <Slider
label={strings.BucketConfigPercentageComplete}
max={100}
value={statebucket.percentageComplete}
ariaValueText={(value: number) => `${value} ${strings.Percent}`}
valueFormat={(value: number) => `${value}%`}
showValue
onChange={(value: number) => {
const bucket = clone(this.state.bucket);
bucket.percentageComplete = value;
this.setState({ bucket: bucket });
}}
/>}
<Toggle label={strings.BucketConfigUseColor} onText="On" offText="Off" inlineLabel
checked={this.state.useColor}
onChange={(ev, checked) => { this.setState({ useColor: checked }); }} />
{this.state.useColor && (<ColorPicker
color={statebucket.color}
// alphaSliderHidden={false}
// showPreview={true}
onChange={(ev: any, colorObj: IColor) => {
const bucket = clone(this.state.bucket);
bucket.color = colorObj.str;
this.setState({ bucket: bucket });
}
}
/>
)}
<Stack>
<PrimaryButton text={strings.BucketConfigSave} onClick={this.submitData.bind(this)} />
<DefaultButton text={strings.BucketConfigReset} onClick={this.resetState.bind(this)} />
</Stack>
</Stack>
);
}
private resetState(): void {
const newbucket: IKanbanBucket = clone(this.props.bucket);
this.setState({
bucket: newbucket,
// showHeadline: newbucket.bucketheadline && newbucket.bucketheadline.length > 0,
useColor: newbucket.color && newbucket.color.length > 0
});
}
private submitData(): void {
const newbucket: IKanbanBucket = clone(this.state.bucket);
if (!this.state.useColor) {
newbucket.color = undefined;
}
/*
if (!this.state.showHeadline) {
newbucket.color = undefined;
}
*/
if (this.props.update) {
this.props.update(this.props.index, newbucket);
}
}
}

View File

@ -0,0 +1,15 @@
.kanbanBoard {
display: flex;
width: 100%;
.bucketwrapper {
border: 1px solid transparent;
display: flex;
flex-direction: row;
align-items: flex-start;
// min-width: 220px;
}
.dragover {
border-color: "[theme: themePrimary, default: #0078d7]";
}
}

View File

@ -0,0 +1,432 @@
import * as React from 'react';
import styles from './KanbanComponent.module.scss';
import bucketstyles from './KanbanBucket.module.scss';
import * as strings from 'KanbanBoardStrings';
import { IKanbanTask, KanbanTaskMamagedPropertyType } from './IKanbanTask';
import { IKanbanBoardTaskSettings } from './IKanbanBoardTaskSettings';
import { IKanbanBoardTaskActions } from './IKanbanBoardTaskActions';
import { IKanbanBoardRenderers } from './IKanbanBoardRenderers';
import { IKanbanBucket } from './IKanbanBucket';
import KanbanBucket from './KanbanBucket';
import KanbanTaskManagedProp from './KanbanTaskManagedProp';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { IStackStyles, Stack } from 'office-ui-fabric-react/lib/Stack';
import { clone } from '@microsoft/sp-lodash-subset';
import { CommandBar } from 'office-ui-fabric-react/lib/CommandBar';
import { TooltipHost, findIndex } from 'office-ui-fabric-react';
export interface IKanbanComponentProps {
buckets: IKanbanBucket[];
tasks: IKanbanTask[];
tasksettings: IKanbanBoardTaskSettings;
taskactions: IKanbanBoardTaskActions;
showCommandbar?: boolean;
renderers?: IKanbanBoardRenderers;
allowEdit?: boolean;
allowAdd?: boolean;
editSchema?: boolean;
/*
showCommandbarNew: boolean;
allowDialog: boolean; TODO im mock
*/
}
export interface IKanbanComponentState {
leavingTaskId?: string;
leavingBucket?: string;
openDialog: boolean;
openTaskId?: string;
dialogState?: DialogState;
editTask?: IKanbanTask;
addBucket?: IKanbanBucket;
}
export enum DialogState {
New = 1,
Edit = 2,
Display = 3
}
export class KanbanComponent extends React.Component<IKanbanComponentProps, IKanbanComponentState> {
private dragelement?: IKanbanTask;
private bucketsref: any[];
constructor(props: IKanbanComponentProps) {
super(props);
this.state = {
openDialog: false,
leavingTaskId: null,
leavingBucket: null,
};
this.bucketsref = [];
}
public render(): React.ReactElement<IKanbanComponentProps> {
const { buckets, tasks, tasksettings, taskactions, showCommandbar } = this.props;
const { openDialog } = this.state;
const bucketwidth: number = buckets.length > 0 ? 100 / buckets.length : 100;
const { leavingBucket, leavingTaskId } = this.state;
const hasprocessIndicator = buckets.filter((b)=> b.showPercentageHeadline).length >0;
return (
<div style={{ overflowX: 'auto' }}>
{showCommandbar && <CommandBar
items={this.getItems()}
farItems={this.getFarItems()}
ariaLabel={'Use left and right arrow keys to navigate between commands'}
/>}
<div className={styles.kanbanBoard}>
{
buckets.map((b, i) => {
const merge = { ...b, ...this.state };
return (<div
style={{
flexBasis: bucketwidth ? bucketwidth + '%' : '100%' ,
maxWidth: bucketwidth ? bucketwidth + '%' : '100%'
}}
className={styles.bucketwrapper}
ref={bucketContent => this.bucketsref[i] = bucketContent}
key={'BucketWrapper' + b.bucket + i}
onDragOver={(event) => this.onDragOver(event, b.bucket)}
onDragLeave={(event) => this.onDragLeave(event, b.bucket)}
onDrop={(event) => this.onDrop(event, b.bucket)}
>
<KanbanBucket
key={b.bucket}
{...merge}
hasOneProcessIndicator={hasprocessIndicator}
buckettasks={tasks.filter((x) => x.bucket == b.bucket)}
tasksettings={tasksettings}
toggleCompleted={this.props.taskactions && this.props.taskactions.toggleCompleted ? this.props.taskactions.toggleCompleted : undefined}
addTask={this.internalAddTask.bind(this)}
openDetails={this.internalOpenDialog.bind(this)}
onDragStart={this.onDragStart.bind(this)}
onDragEnd={this.onDragEnd.bind(this)}
/>
</div>);
}
)}
</div>
{openDialog && (this.renderDialog())}
</div>
);
}
private getTaskByID(taskId: string): IKanbanTask {
const tasks = this.props.tasks.filter(t => t.taskId == this.state.openTaskId);
if (tasks.length == 1) {
return tasks[0];
}
throw "Error Taks not found by taskId";
}
private renderDialog(): JSX.Element {
let renderer: (task?: IKanbanTask, bucket?: IKanbanBucket) => JSX.Element = () => (<div>Dialog Renderer Not Set</div>);
let task: IKanbanTask = undefined;
let bucket: IKanbanBucket = undefined;
let dialogheadline: string = '';
switch (this.state.dialogState) {
case DialogState.Edit:
task = this.getTaskByID(this.state.openTaskId);
renderer = this.internalTaskEditRenderer.bind(this);
dialogheadline = strings.EditTaskDlgHeadline;
break;
case DialogState.New:
renderer = this.internalTaskAddRenderer.bind(this);
dialogheadline = strings.AddTaskDlgHeadline;
break;
default:
task = this.getTaskByID(this.state.openTaskId);
dialogheadline = task.title;
renderer = (this.props.renderers && this.props.renderers.taskDetail) ? this.props.renderers.taskDetail : this.internalTaskDetailRenderer.bind(this);
break;
}
return (<Dialog
minWidth={600}
hidden={!this.state.openDialog}
onDismiss={this.internalCloseDialog.bind(this)}
dialogContentProps={{
type: DialogType.largeHeader,
title: dialogheadline,
subText: ''
}}
modalProps={{
isBlocking: false,
styles: { main: { minWidth: 600 } }
}}
>
{renderer(task, bucket)}
<DialogFooter>
{(this.props.allowEdit && this.state.dialogState === DialogState.Display) &&
(<PrimaryButton onClick={this.clickEditTask.bind(this)} text={strings.EditTaskBtn} />)}
{(this.props.allowEdit && this.state.dialogState === DialogState.Edit) &&
(<PrimaryButton onClick={this.saveEditTask.bind(this)} text={strings.SaveTaskBtn} />)}
{(this.props.allowAdd && this.state.dialogState === DialogState.New) &&
(<PrimaryButton onClick={this.saveAddTask.bind(this)} text={strings.SaveAddTaskBtn} />)}
<DefaultButton onClick={this.internalCloseDialog.bind(this)} text={strings.CloseTaskDialog} />
</DialogFooter>
</Dialog>);
}
private clickEditTask(): void {
const task = this.getTaskByID(this.state.openTaskId);
if (this.props.taskactions.taskEdit) {
this.internalCloseDialog();
this.props.taskactions.taskEdit(clone(task));
} else {
this.setState({
dialogState: DialogState.Edit,
editTask: clone(task)
});
}
}
private saveEditTask() {
if (this.props.taskactions.editTaskSaved) {
const edittask = clone(this.state.editTask);
//check fist state and than event or in the other way
this.internalCloseDialog();
this.props.taskactions.editTaskSaved(edittask);
} else {
throw "allowEdit is Set but no handler is set";
}
}
private saveAddTask() {
if (this.props.taskactions.editTaskSaved) {
const edittask = clone(this.state.editTask);
//check fist state and than event or in the other way
this.internalCloseDialog();
this.props.taskactions.editTaskSaved(edittask);
} else {
throw "allowAdd is Set but no handler is set";
}
}
private internalTaskDetailRenderer(task: IKanbanTask): JSX.Element {
const { tasksettings } = this.props;
return (<Stack>
{tasksettings && tasksettings.showPriority && (
<KanbanTaskManagedProp
name="assignedTo"
displayName={strings.Priority}
type={KanbanTaskMamagedPropertyType.string}
value={task.priority}
key={'assignedToProp'} />
)}
{tasksettings && tasksettings.showAssignedTo && (<KanbanTaskManagedProp
name="assignedTo"
displayName={strings.AssignedTo}
type={KanbanTaskMamagedPropertyType.person}
value={task.assignedTo}
key={'assignedToProp'} />
)}
<KanbanTaskManagedProp
name="assignedTo"
displayName={strings.HtmlDescription}
type={KanbanTaskMamagedPropertyType.html}
value={task.htmlDescription}
key={'htmlDescriptionProp'} />
{task.mamagedProperties && (
task.mamagedProperties.map((p, i) => {
return (
<KanbanTaskManagedProp {...p} key={p.name + i} />
);
})
)}
</Stack>
);
}
private internalTaskEditRenderer(task: IKanbanTask): JSX.Element {
const schema = this.props.editSchema; //TODO
return (<div>Edit</div>);
}
private internalTaskAddRenderer(task?: IKanbanTask, bucket?: IKanbanBucket): JSX.Element {
const schema = this.props.editSchema; //TODO
return (<div>New</div>);
}
private internalCloseDialog(ev?: React.MouseEvent<HTMLButtonElement>) {
this.setState({
openDialog: false,
openTaskId: undefined,
dialogState: undefined,
editTask: undefined,
addBucket: undefined
});
}
private internalOpenDialog(taskid: string) {
this.setState({
openDialog: true,
openTaskId: taskid,
dialogState: DialogState.Display
});
}
private internalAddTask(targetbucket?: string) {
let bucket: IKanbanBucket = undefined;
if (bucket) {
const buckets = this.props.buckets.filter((p) => p.bucket === targetbucket);
if (buckets.length === 1) {
bucket = clone(buckets[0]);
} else {
throw "Bucket not Found in addDialog";
}
}
if (this.props.taskactions && this.props.taskactions.taskAdd) {
this.props.taskactions.taskAdd(bucket);
} else {
this.setState({
openDialog: true,
openTaskId: '',
dialogState: DialogState.New,
addBucket: bucket
});
}
}
private onDragLeave(event, bucket): void {
const index = findIndex(this.props.buckets, element => element.bucket == bucket);
if (index != -1 && this.bucketsref.length > index) {
//&& this.bucketsref[index].classList.contains(styles.dragover)) {
this.bucketsref[index].classList.remove(styles.dragover);
}
}
private onDragEnd(event): void {
this.dragelement = undefined;
this.setState({
leavingTaskId: null,
leavingBucket: null,
});
}
private onDragStart(event, taskId: string, bucket: string): void {
const taskitem = this.props.tasks.filter(p => p.taskId === taskId);
if (taskitem.length === 1) {
event.dataTransfer.setData("text", taskId);
event.dataTransfer.effectAllowed = 'copy';
//event.dataTransfer.setData("sourcebucket", bucket);
//set element because event.dataTransfer is empty by DragOver
this.dragelement = taskitem[0];
this.setState({
leavingTaskId: taskId,
leavingBucket: bucket,
});
} else {
// Error not consitent
throw "TaskItem not found on DragStart";
}
}
private onDragOver(event, targetbucket: string): void {
event.preventDefault();
if (this.dragelement.bucket !== targetbucket) {
const index = findIndex(this.props.buckets, element => element.bucket == targetbucket);
if (index > -1 && this.bucketsref.length > index) {
//&& this.bucketsref[index].classList.contains(styles.dragover)) {
this.bucketsref[index].classList.add(styles.dragover);
}
}
}
private onDrop(event, targetbucket: string): void {
if (this.bucketsref && this.bucketsref.length > 0) {
this.bucketsref.forEach(x => { x.classList.remove(styles.dragover); });
}
if (this.dragelement.bucket !== targetbucket) {
//event.dataTransfer.getData("text");
const taskId = this.dragelement.taskId;
const source = this.props.buckets.filter(s => s.bucket == this.dragelement.bucket)[0];
const target = this.props.buckets.filter(s => s.bucket == targetbucket)[0];
if (this.props.taskactions) {
let allowMove = true;
if (this.props.taskactions.allowMove) {
allowMove = this.props.taskactions.allowMove(taskId,
source,
target
);
}
if (allowMove && this.props.taskactions.moved) {
this.props.taskactions.moved(taskId, target);
}
}
}
this.dragelement = null;
this.setState({
leavingTaskId: null,
leavingBucket: null,
});
}
private getItems = () => {
if (this.props.allowAdd) {
return [
{
key: 'newItem',
name: 'New',
cacheKey: 'myAddBtnKey',
iconProps: {
iconName: 'Add'
},
onClick: () => this.internalAddTask.bind(this)
}];
}
return [];
}
private getFarItems = () => {
return [
{
key: 'info',
name: 'Info',
ariaLabel: 'Info',
iconProps: {
iconName: 'Info'
},
iconOnly: true,
onClick: () => console.log('Info')
}
];
}
}

View File

@ -0,0 +1,45 @@
.taskcard {
padding: 5px;
background-color:initial;
// box-shadow: $ms-depth-shadow-8;
box-shadow: 0 3.2px 7.2px 0 rgba(0,0,0,.132), 0 0.6px 1.8px 0 rgba(0,0,0,.108);
.titlerow {
display: table-row;
width: 100%;
max-width: 0;
.isCompleted {
display: table-cell;
width: 32px;
}
.title {
display: table-cell;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
max-width: 0;
width: 100%;
}
.details {
display: table-cell;
width: 32px;
}
}
.membersAndLabels {
display: block;
width: 100%;
.labels,
.priority,
.assignedto {
padding: 7px 0px;
display: block;
width: 100%;
overflow: hidden;
}
}
}
.moving {
background-color:"[theme: neutralLight, default: #eaeaea]";
}

View File

@ -0,0 +1,93 @@
import * as React from 'react';
import styles from './KanbanTask.module.scss';
import * as strings from 'KanbanBoardStrings';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import { IKanbanTask } from './IKanbanTask';
import { IKanbanBoardTaskSettings } from './IKanbanBoardTaskSettings';
import classNames from 'classnames';
import { Persona, PersonaSize } from 'office-ui-fabric-react';
export interface IKanbanTaskProps extends IKanbanTask, IKanbanBoardTaskSettings {
toggleCompleted?: (taskId: string) => void;
openDetails: (taskId: string) => void;
onDragStart: (event) => void;
onDragEnd: (event) => void;
isMoving: boolean;
}
export interface IKanbanTaskState { }
export default class KanbanTask extends React.Component<IKanbanTaskProps, IKanbanTaskState> {
constructor(props: IKanbanTaskProps) {
super(props);
this.state = {};
}
public render(): React.ReactElement<IKanbanTaskProps> {
const { title, showPriority, showAssignedTo, isCompleted, isMoving, showTaskDetailsButton } = this.props;
const showCompleted = !!this.props.toggleCompleted;
const iconCompleted = { iconName: isCompleted ? 'RadioBtnOn' : 'RadioBtnOff' };
/*
className={classNames({ [styles.taskcard]: true, [styles.moving]: isMoving })}
*/
return (
<div
className={classNames({ [styles.taskcard]: true, [styles.moving]: isMoving })}
onDragStart={this.props.onDragStart}
onDragEnd={this.props.onDragEnd}
draggable
>
<div className={styles.titlerow}>
{showCompleted && (
<div className={styles.isCompleted} ><IconButton
iconProps={iconCompleted}
title={isCompleted ? strings.IsCompleted : strings.IsNotCompleted}
ariaLabel={isCompleted ? strings.IsCompleted : strings.IsNotCompleted}
onClick={this._toggleCompleted.bind(this)}
/></div>)
}
<div className={styles.title}>{title}</div>
{showTaskDetailsButton && (
<div className={styles.details}><IconButton
iconProps={{ iconName: 'More' }}
title={strings.OpenDetails}
ariaLabel={strings.OpenDetails}
onClick={this._openDetails.bind(this)}
/></div>)
}
</div>
<div className={styles.membersAndLabels}>
{showPriority && this.props.priority && (<div className={styles.priority}>{this.props.priority}</div>)}
{showAssignedTo && this.props.assignedTo && (<div className={styles.assignedto}>
<Persona
key={'assingedto'}
{...this.props.assignedTo}
size={PersonaSize.size32}
hidePersonaDetails={false}
/>
</div>)}
</div>
</div>
);
}
private _toggleCompleted(): void {
if (this.props.toggleCompleted) {
this.props.toggleCompleted(this.props.taskId);
}
}
private _openDetails(): void {
if (this.props.openDetails) {
this.props.openDetails(this.props.taskId);
}
}
}

View File

@ -0,0 +1,6 @@
.rowcol1{
flex-basis: 30%;
}
.rowcol2{
flex-basis: 70%;
}

View File

@ -0,0 +1,84 @@
import * as React from 'react';
import styles from './KanbanTaskManagedProp.module.scss';
import { IKanbanTaskManagedProps, KanbanTaskMamagedPropertyType } from './IKanbanTask';
import { Stack } from 'office-ui-fabric-react/lib/Stack';
import ReactHtmlParser from 'react-html-parser';
import { Persona, PersonaSize, IPersonaProps } from 'office-ui-fabric-react';
export interface IKanbanTaskManagedPropProps extends IKanbanTaskManagedProps { }
export interface IKanbanTaskManagedPropState { }
export default class KanbanTaskManagedProp extends React.Component<IKanbanTaskManagedPropProps, IKanbanTaskManagedPropState> {
public render(): React.ReactElement<IKanbanTaskManagedPropProps> {
const { displayName, name } = this.props;
return (
<Stack horizontal horizontalAlign="stretch">
<Stack.Item align="auto" className={styles.rowcol1}>
<span>{displayName ? displayName : name}</span>
</Stack.Item>
<Stack.Item align="stretch" className={styles.rowcol2}>
{this.renderValue()}
</Stack.Item>
</Stack>
);
}
private renderValue() {
const { name, type, value } = this.props;
if (this.props.renderer) {
return this.props.renderer(name, value, type);
}
switch (this.props.type) {
case KanbanTaskMamagedPropertyType.string:
return (<span>{value} </span>);
break;
case KanbanTaskMamagedPropertyType.number:
return (<span>{value} </span>);
//TODO maybe Formater
break;
case KanbanTaskMamagedPropertyType.percent:
return (<span>{`${(value as any) * 100}%`} </span>);
//TODO maybe better Formater
break;
case KanbanTaskMamagedPropertyType.html:
return (<span>{ReactHtmlParser(value)}</span>);
break;
case KanbanTaskMamagedPropertyType.person:
return (<span>
{value && (
<Persona
{...value}
size={PersonaSize.size32}
hidePersonaDetails={false} />
)}
</span>);
break;
case KanbanTaskMamagedPropertyType.persons:
return (<span>
{
(value && (
(value as IPersonaProps[]).map((p, i) => (<Persona
key={'persona' + i}
{...value}
size={PersonaSize.size32}
hidePersonaDetails={false}
/>))
)
)
}
};
</span>);
break;
case KanbanTaskMamagedPropertyType.complex:
return (<span>{JSON.stringify(value)}</span>);
break;
default:
throw "Unknow KanbanTaskMamagedPropertyType";
break;
}
}
}

View File

@ -0,0 +1,129 @@
import * as React from 'react';
import {KanbanComponent} from './KanbanComponent';
import {KanbanBucketConfigurator} from './KanbanBucketConfigurator';
import { IKanbanBucket } from './IKanbanBucket';
import { IKanbanTask, KanbanTaskMamagedPropertyType } from './IKanbanTask';
import { findIndex } from "lodash";
import { cloneDeep, clone } from '@microsoft/sp-lodash-subset';
export interface IMockKanbanProps { }
export interface IMockKanbanState {
buckets: IKanbanBucket[];
tasks: IKanbanTask[];
}
export class MockKanban extends React.Component<IMockKanbanProps, IMockKanbanState> {
constructor(props: IMockKanbanProps) {
super(props);
this.state = {
buckets: [
{ bucket: 'Not Started', bucketheadline: 'Not Started Head', percentageComplete: 0, color: 'yellow', allowAddTask: true },
{ bucket: 'Test1', bucketheadline: 'Test1 Head', percentageComplete: 10, color: 'orange', allowAddTask: true },
{ bucket: 'Test2', bucketheadline: 'Test2 Head', percentageComplete: 50, color: 'green' },
{ bucket: 'Test3', bucketheadline: 'Test3 Head', percentageComplete: 50, color: '#FF0000' },
{ bucket: 'Test4', bucketheadline: 'Test4 Head', percentageComplete: 0, allowAddTask: true }
],
tasks: [
{
taskId: '1', title: 'test1', bucket: 'Not Started',
mamagedProperties: [
{
name: 'Prop1',
displayName: 'Prop1 Display',
type: KanbanTaskMamagedPropertyType.html,
value: '<p>test<b>Bold</b></p>'
},
{
name: 'Prop2',
displayName: 'Prop2 Display',
type: KanbanTaskMamagedPropertyType.complex,
value: '<p>test<b>Bold</b></p>',
renderer: (name, value, type) => { return (<span>SampleRenderer</span>); }
},
{
name: 'Prop3',
displayName: 'String',
type: KanbanTaskMamagedPropertyType.string,
value: 'Hallo World'
}
]
},
{ taskId: '2', title: 'test2', bucket: 'Not Started' },
{ taskId: '3', title: 'test3', bucket: 'Not Started' },
{ taskId: '4', title: 'test 4', bucket: 'Test4' },
{ taskId: '5', title: 'test 5', bucket: 'Test3' },
]
};
}
public render(): React.ReactElement<IMockKanbanProps> {
const { buckets, tasks } = this.state;
return (
<div>
<KanbanComponent
buckets={buckets}
tasks={tasks}
tasksettings={{
showPriority: true,
showAssignedTo: true,
showTaskDetailsButton: true
}
}
taskactions={{
toggleCompleted: this._toggleCompleted.bind(this),
allowMove: this._allowMove.bind(this),
moved: this._moved.bind(this),
}}
showCommandbar={true}
/>
<div>
<h2>
Bucket Configuration sample
</h2>
{
this.state.buckets.map((b, i) =>
<KanbanBucketConfigurator
key={'BucketConfig' + i}
index={i}
bucket={b}
update={this.updateBucket.bind(this)}
/>
)
}
</div>
</div>
);
}
private updateBucket(index: number, value: IKanbanBucket) {
debugger;
const cstate = cloneDeep(this.state);
cstate.buckets[index] = clone(value);
this.setState(cstate);
}
private _toggleCompleted(taskId: string): void {
//TODO
}
private _allowMove(taskId: string, prevBucket: IKanbanBucket, targetBucket: IKanbanBucket): boolean {
if (prevBucket.bucket === 'Test2' && targetBucket.bucket === 'Test3') {
return false;
}
return true;
}
private _moved(taskId: string, targetBucket: IKanbanBucket): void {
const elementsIndex = findIndex(this.state.tasks, element => element.taskId == taskId);
let newArray = [...this.state.tasks];
newArray[elementsIndex].bucket = targetBucket.bucket;
this.setState({ tasks: newArray });
}
}

View File

@ -0,0 +1,209 @@
# KanbanComponent Control
This control renders a KanbanBoard which can be used to show Tasks and move it from one State to an Other.
**Control in Action**
![KanbanBoard control](../../assets/kanbanofficeUI.gif)
## How to use this control in your solutions
this component is not Extracted as an NPM Package
Copy this Folder
In the Files ```MockKanban.tsx``` you can find many Configuration Options
```typescript
<KanbanComponent
buckets={buckets}
tasks={tasks}
tasksettings={{
showPriority: true,
showAssignedTo: true,
showTaskDetailsButton: true
}
}
taskactions={{
toggleCompleted: this._toggleCompleted.bind(this),
allowMove: this._allowMove.bind(this),
moved: this._moved.bind(this),
}}
showCommandbar={true}
/>
```
Buckets
```
[
{bucket:'Not Started', bucketheadline:'Not Started Head',percentageComplete:0, color:'yellow' ,allowAddTask:true},
{bucket:'Test1', bucketheadline:'Test1 Head',percentageComplete:10, color:'orange',allowAddTask:true },
{bucket:'Test2', bucketheadline:'Test2 Head',percentageComplete:50, color:'green' },
]
```
Tasks
```
[
{taskId: '1', title:'test1',bucket:'Not Started'},
{taskId: '5', title:'test 5',bucket:'Test3'}
]
```
## Implementation
The KanbanBoard control can be configured with the following properties:
### IKanbanBucket
| Property | Type | Required | Description | Default |
| ------------------ | ------ | -------- | ------------------------------------------------ | ----------------------- |
| bucket | string | * | internalname | |
| bucketheadline | string | | Optional Headline | name of property bucket |
| percentageComplete | number | | Percentage of Bucket shows in bar under Headline |
| color | string | | | |
### IKanbanTask
| Property | Type | Required | Description | Default |
| ----------------- | ----------------------- | -------- | ----------- | ------- |
| taskId | string | * | | |
| title | string | * | | |
| assignedTo | IPersonaProps | | | |
| htmlDescription | string | | | |
| priority | string | | | |
| bucket | string | * | | |
| mamagedProperties | IKanbanTaskManagedProps | | | |
### IKanbanComponentProps
| Property | Type | Required | Description | Default |
| -------------- | ------------------------ | -------- | ----------- | ------- |
| buckets | IKanbanBucket[] | * | | |
| tasks | IKanbanTask[] | * | | |
| tasksettings | IKanbanBoardTaskSettings | * | | |
| taskactions | IKanbanBoardTaskActions | * | | |
| showCommandbar | boolean | | | false |
| renderers | IKanbanBoardRenderers | | | |
| allowAdd? | boolean | | | false |
#### IKanbanBoardTaskSettings
| Property | Type | Required | Description | Default |
| --------------------- | ------- | -------- | ----------- | ------- |
| showPriority | boolean | * | | |
| showAssignedTo | boolean | * | | |
| showTaskDetailsButton | boolean | * | | |
#### IKanbanBoardTaskActions
| Property | Type | Required | Description | Default |
| --------------- | ----------------------------------------------------------------------------------- | -------- | ----------- | ------- |
| toggleCompleted | (taskId: string) => void | | | |
| allowMove | taskId: string, prevBucket: IKanbanBucket, targetBucket: IKanbanBucket) => boolean | | | |
| moved | (taskId: string, targetBucket: IKanbanBucket) => void; | | | |
#### IKanbanBoardRenderers
| Property | Type | Required | Description | Default |
| --------------- | ------------------------------------- | -------- | ----------- | ------- |
| task? | (task:IKanbanTask) => JSX.Element | | | |
| bucketHeadline? | (bucket:IKanbanBucket) => JSX.Element | | | |
| taskDetail? | (task:IKanbanTask) => JSX.Element | | |
#### IKanbanTaskManagedProps
| Property | Type | Required | Description | Default |
| ----------- | --------------------------------------------------------------------------------- | -------- | ----------- | ------- |
| name | string | * | | |
| displayName | string | | | |
| type: | KanbanTaskMamagedPropertyType | * | | |
| value | string /number/ IPersonaProps/ IPersonaProps[] / any | * | | |
| renderer | (name: string, value: object, type: KanbanTaskMamagedPropertyType) => JSX.Element | | | |
IPersonaProps reference to Office UI Fabric
#### KanbanTaskMamagedPropertyType
| Type | Value | Description |
| ------- | ----- | ------------------------------------------- |
| string | 1 | value |
| number | 2 | value |
| percent | 3 | value * 100 % |
| html | 4 | value with html string |
| person | 5 | Office Ui Persona (value:IPersonaProps) |
| persons | 6 | Office Ui Persona's (value:IPersonaProps[]) |
| complex | 7 | JSON.stringify(value) |
}
## Samples
### Custom Detail View Renderer
```typescript
renderers.taskDetail?: (task:IKanbanTask) => JSX.Element ;
```
```typescript
renderers.taskDetail?: (task:IKanbanTask) => JSX.Element ;
private _taskDetailRenderer(task:IKanbanTask): JSX.Element {
return (<div>My Cool Content!!!</div>)
}
```
### Use SharePoint New and Edit Form
´´´typescript
taskactions.taskEdit?: (task: IKanbanTask) => void ;
taskactions.taskAdd?: (bucket?: IKanbanBucket) => void ;
´´´
Open in Dialog (Iframe) or new Tab (NewForm.aspx od EditForm.aspx) or make your custom Form
```typescript
private _taskAdd(bucket?: IKanbanBucket): void {
window.open(this.listurl+'/NewForm.aspx)
}
private _taskEditd(task: IKanbanTask): void {
window.open(this.listurl+'/EditFrom.aspx?ID='+task.taskId')
}
```
### Disallow Move from One Bucket to an Other (allowMove)
´´´typescript
taskactions.allowMove: (taskId: string, prevBucket: IKanbanBucket, targetBucket: IKanbanBucket) => boolean;
```
```typescript
private _allowMove(taskId: string, prevBucket: IKanbanBucket, targetBucket: IKanbanBucket): boolean {
if (prevBucket.bucket === 'Test2' && targetBucket.bucket === 'Test3') {
return false;
}
return true;
}
```
## Leanings
Read more about [Drag and Drop](https://petkir.wordpress.com/2020/07/01/drag-and-drop-in-react-spfx/)
```
IKanbanTask {
taskId: string;
}
```
has to be a string because, i use event ```event.dataTransfer.setData``` and this accespts only strings in IE
```
event.dataTransfer.setData('text',1)
Invalid argument.
```
The second big thing is IE allows only to set the value 'text' event.dataTransfer.setData('text','1')
```
event.dataTransfer.setData('xyz','1')
Unexpected call to method or property access.
```
-------------------------------
## Future
* think about Promise Task Actions, because actions are async
* EditSchema To support Edit

View File

@ -0,0 +1,4 @@
export { KanbanComponent } from './KanbanComponent';
export { KanbanBucketConfigurator ,IKanbanBucketConfiguratorProps } from './KanbanBucketConfigurator';
export { IKanbanBucket } from './IKanbanBucket';
export { IKanbanTask, KanbanTaskMamagedPropertyType } from './IKanbanTask';

View File

@ -0,0 +1,30 @@
define([], function () {
return {
"CompleteButton": "Complete",
"IsCompleted": "Is Completed",
"IsNotCompleted": "Is Not Completed",
"AddTask": "Add Task",
"OpenDetails": "Open Details",
"EditTaskBtn": "Edit",
"SaveTaskBtn": "Save and Close",
"SaveAddTaskBtn": "Add and Close",
"CloseTaskDialog": "Close",
"AddTaskDlgHeadline": "Add Task",
"EditTaskDlgHeadline": "Edit Task",
"AssignedTo": "Assigned to",
"HtmlDescription": "Description",
"Priority": "Priority",
"BucketConfigInternalName": "Internal Name",
"BucketConfigHeadline": "Headline",
"BucketConfigPercentageComplete": "Bucket State Percentage Complete",
"BucketConfigAllowAddTask": "Add Task?",
"BucketConfigUseColor": "Use Color?",
"BucketConfigSave": "Save",
"BucketConfigReset": "Reset",
"Percent":"percent",
"BucketConfigShowPercentage": "Show Processindicator",
"BucketConfigShowPercentageShow": "Show",
"BucketConfigShowPercentageHide": "Hide"
}
});

View File

@ -0,0 +1,34 @@
declare interface IKanbanBoardStrings {
CompleteButton: string;
IsCompleted: string;
IsNotCompleted: string;
AddTask: string;
OpenDetails: string;
EditTaskBtn: string;
SaveTaskBtn: string;
SaveAddTaskBtn: string;
CloseTaskDialog: string;
AddTaskDlgHeadline: string;
EditTaskDlgHeadline: string;
AssignedTo: string;
HtmlDescription: string;
Priority: string;
BucketConfigInternalName: string;
BucketConfigHeadline: string;
BucketConfigPercentageComplete: string;
BucketConfigAllowAddTask: string;
BucketConfigUseColor: string;
BucketConfigSave: string;
BucketConfigReset: string;
Percent: string;
BucketConfigShowPercentage: string;
BucketConfigShowPercentageShow: string;
BucketConfigShowPercentageHide: string;
}
declare module 'KanbanBoardStrings' {
const strings: IKanbanBoardStrings;
export = strings;
}

View File

@ -1,49 +1,79 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library'; import { Version, Environment, EnvironmentType } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart, PropertyPaneDropdown } from '@microsoft/sp-webpart-base'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { import {
IPropertyPaneConfiguration, IPropertyPaneConfiguration,
PropertyPaneTextField PropertyPaneToggle
} from '@microsoft/sp-property-pane'; } from '@microsoft/sp-property-pane';
import {sp} from '@pnp/sp'; import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { PropertyFieldListPicker, PropertyFieldListPickerOrderBy } from '@pnp/spfx-property-controls/lib/PropertyFieldListPicker';
import { PropertyFieldOrder } from '@pnp/spfx-property-controls/lib/PropertyFieldOrder';
import * as strings from 'KanbanBoardWebPartStrings'; import * as strings from 'KanbanBoardWebPartStrings';
import KanbanBoard from './components/KanbanBoard'; import "@pnp/polyfill-ie11";
import { IKanbanBoardProps } from './components/IKanbanBoardProps'; import { sp } from '@pnp/sp';
import PropertyPaneBucketConfigComponent from './components/PropertyPaneBucketConfig';
import KanbanBoardV2, { IKanbanBoardV2Props } from './components/KanbanBoardV2';
import { bucketOrder } from './components/bucketOrder';
import { mergeBucketsWithChoices } from './components/helper';
import { IKanbanBucket } from '../../kanban';
import { ISPKanbanService } from './services/ISPKanbanService';
import SPKanbanService from './services/SPKanbanService';
import MockKanbanService from './services/MockKanbanService';
export interface IKanbanBoardWebPartProps { export interface IKanbanBoardWebPartProps {
listTitle: string; hideWPTitle: boolean;
lists: Array<any>; title: string;
buckets: IKanbanBucket[];
listId: string;
listTitle: string; //was the name if upgrade support than (remap title to id)
loaded: boolean; loaded: boolean;
} }
export default class KanbanBoardWebPart extends BaseClientSideWebPart<IKanbanBoardWebPartProps> { export default class KanbanBoardWebPart extends BaseClientSideWebPart<IKanbanBoardWebPartProps> {
private kanbanComponent = null;
private dataService: ISPKanbanService;
private statekey: string = Date.now().toString();
public onInit(): Promise<void> { public onInit(): Promise<void> {
return super.onInit().then(_ => { return super.onInit().then(_ => {
sp.setup({ sp.setup({
spfxContext: this.context spfxContext: this.context
}); });
if (Environment.type == EnvironmentType.Local || Environment.type == EnvironmentType.Test) {
this.dataService = new MockKanbanService();
} else {
this.dataService = new SPKanbanService();
}
}); });
} }
public render(): void { public render(): void {
const element: React.ReactElement<IKanbanBoardV2Props> = React.createElement(
KanbanBoardV2,
const element: React.ReactElement<IKanbanBoardProps > = React.createElement(
KanbanBoard,
{ {
listTitle: this.properties.listTitle, hideWPTitle: this.properties.hideWPTitle,
webUrl: this.context.pageContext.web.absoluteUrl title: this.properties.title,
displayMode: this.displayMode,
updateProperty: (value: string) => {
this.properties.title = value;
},
statekey: this.statekey,
context: this.context,
listId: this.properties.listId,
configuredBuckets: this.properties.buckets
} }
); );
ReactDom.render(element, this.domElement);
this.kanbanComponent = ReactDom.render(element, this.domElement);
} }
protected onDispose(): void { protected onDispose(): void {
@ -55,35 +85,130 @@ export default class KanbanBoardWebPart extends BaseClientSideWebPart<IKanbanBoa
} }
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return { const propertypages = [];
pages: [
{ const generalgroups = [];
groups: [ generalgroups.push(
{ {
groupName: strings.BasicGroupName, groupName: strings.BasicGroupName,
groupFields: [ groupFields: [
PropertyPaneDropdown('listTitle',{ PropertyPaneToggle('hideWPTitle', {
label: strings.ListTitleFieldLabel, label: strings.propertyPaneHideWPHeadline,
options: this.properties.lists checked: this.properties.hideWPTitle,
}) onText: strings.propertyPaneHideWPHeadlineHide,
] offText: strings.propertyPaneHideWPHeadlineShow
}),
PropertyFieldListPicker('listId', {
label: strings.propertyPaneSelectList,
selectedList: this.properties.listId,
includeHidden: false,
orderBy: PropertyFieldListPickerOrderBy.Title,
disabled: false,
onPropertyChange: this.listConfigurationChanged.bind(this),
properties: this.properties,
context: this.context,
onGetErrorMessage: null,
deferredValidationTime: 0,
key: 'listPickerFieldId',
onListsRetrieved: (lists) => {
//TODO Check from TS Definition it should be a string but i get a number
// with Typesafe equal it fails
if (Environment.type == EnvironmentType.Local || Environment.type == EnvironmentType.Test) {
return lists;
} else {
const alists = lists.filter((l: any) => {
return (l.BaseTemplate === 171 || l.BaseTemplate === 107);
});
return alists;
}
} }
})
]
});
if (this.properties.listId && this.properties.buckets && this.properties.buckets.length > 1) {
generalgroups.push(
{
groupName: strings.propertyPaneLabelOrderBuckets,
groupFields: [
PropertyFieldOrder("buckets", {
key: "orderedItems",
label: strings.propertyPaneLabelOrderBuckets,
items: this.properties.buckets,
properties: this.properties,
onPropertyChange: this.onPropertyPaneFieldChanged,
onRenderItem: bucketOrder,
})
] ]
} }
] );
}
propertypages.push({
groups: generalgroups
});
if (this.properties.buckets && this.properties.buckets.length > 0) {
this.properties.buckets.forEach((b, i) => {
propertypages.push({
key: { i },
header: {
description: strings.propertyPaneBucketConfiguration
},
groups: [{
groupName: b.bucketheadline ? b.bucketheadline : b.bucket,
groupFields: [
PropertyPaneBucketConfigComponent('bucket_' + i, {
key: 'bucket_' + i,
properties: cloneDeep(b),
onPropertyChange: this.bucketConfigurationChanged.bind(this)
})
]
}
]
});
});
}
return {
pages: propertypages
}; };
} }
protected onPropertyPaneConfigurationStart(){ private listConfigurationChanged(propertyPath: string, oldValue: any, newValue: any) {
// Use the list template ID to locate both the old style task lists (107) and newer task lists (171) this.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
sp.web.lists.filter("BaseTemplate eq 171 or BaseTemplate eq 107").select("Title").get().then(res => { this.refreshBucket();
this.properties.lists = res.map((val,index) => {
return {
key: val.Title,
text: val.Title
};
});
this.context.propertyPane.refresh();
});
} }
private bucketConfigurationChanged(propertyPath: string, oldValue: any, newValue: any) {
//its an array part !!!!!
if (propertyPath.indexOf('bucket_') !== -1) {
const oribuckets: IKanbanBucket[] = cloneDeep(this.properties.buckets);
const newbuckets: IKanbanBucket[] = cloneDeep(this.properties.buckets);
const bucketindex: number = +propertyPath.split('_')[1];
newbuckets[bucketindex] = newValue;
//maybe better to make a array control (Update)
this.onPropertyPaneFieldChanged("buckets", oribuckets, newbuckets);
this.properties.buckets = newbuckets;
this.context.propertyPane.refresh();
this.render();
} else {
throw "propertypath is not a bucket";
}
}
private refreshBucket(): void {
const listId = this.properties.listId;
if (!listId || listId.length === 0) { return; }
this.dataService.getBuckets(listId).then((x) => {
const currentbuckets: IKanbanBucket[] = mergeBucketsWithChoices(this.properties.buckets, x);
if (!currentbuckets) {
return;
}
this.properties.buckets = currentbuckets;
this.context.propertyPane.refresh();
}
);
}
} }

View File

@ -1,4 +0,0 @@
export interface IKanbanBoardProps {
listTitle: string;
webUrl: string;
}

View File

@ -1,74 +0,0 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.kanbanBoard {
.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);
}
.row {
@include ms-Grid-row;
@include ms-fontColor-white;
background-color: $ms-color-themeDark;
padding: 20px;
}
.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;
}
.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

@ -1,267 +0,0 @@
import * as React from 'react';
import styles from './KanbanBoard.module.scss';
import { IKanbanBoardProps } from './IKanbanBoardProps';
import {sp} from '@pnp/sp';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { IStackStyles, Stack } from 'office-ui-fabric-react/lib/Stack';
import ReactHtmlParser, { processNodes, convertNodeToElement, htmlparser2 } from 'react-html-parser';
import 'jqwidgets-scripts/jqwidgets/styles/jqx.base.css';
import 'jqwidgets-scripts/jqwidgets/styles/jqx.metro.css';
import JqxKanban, { IKanbanProps, jqx, IKanbanSource } from 'jqwidgets-scripts/jqwidgets-react-tsx/jqxkanban';
const rowStyle1: IStackStyles = {
root:{
flexBasis:"30%"
}
};
const rowStyle2: IStackStyles = {
root:{
flexBasis:"70%"
}
};
export interface ICustomKanbanProps extends IKanbanProps{
listTitle: string;
showBoard: boolean;
noItems: boolean;
taskDetails: IKanbanSource;
hideDialog: boolean;
}
export default class KanbanBoard extends React.Component<IKanbanBoardProps, ICustomKanbanProps, {}> {
private sourceFields: any[] = [
{ name: 'id', map:'Id', type: 'string' },
{ name: 'status', map: 'Status', type: 'string' },
{ name: 'text', map: 'Title', type: 'string' },
{ name: 'tags',map:'Priority', type: 'string' },
{ name: 'color',map:'PercentComplete', type: 'string' },
{ name: 'resourceId', map: 'AssignedToId', type: 'number' },
{ name: 'content', map:'Body', type: 'string'},
{ name: 'percent', map:'PercentComplete', type: 'number'},
{ name: 'priority', map:'Priority', type: 'string'}
];
private resourceFields : any[] = [
{ name: 'id', map:'Id', type: 'number' },
{ name: 'name', map:'Title', type: 'string' },
{ name: 'image', type: 'string' },
{ name: 'common', type: 'boolean' },
{ name: 'email', type: 'string', map: 'Email' }
];
constructor(props: IKanbanBoardProps) {
super(props);
const template: string =
'<div class="jqx-kanban-item" id="" style="border-radius:0px;">'
+ '<div class="jqx-kanban-item-color-status"></div>'
+ '<div class="jqx-kanban-item-avatar"></div>'
+ '<div class="jqx-kanban-item-text"></div>'
+ '<div class="jqx-kanban-item-footer">'
+ '<div style="float:right;"><div class="jqx-kanban-item-template-content"><i data-icon-name="More" role="presentation" aria-hidden="true" class="ms-Icon root-48" style="font-family: &quot;FabricMDL2Icons&quot;;font-size: 1.25em;color: gray;"></i></div></div>'
+ '</div></div>';
const itemRenderer = (element: any, item: any, resource: any): void => {
let precentComplete = item.color as Number;
let style = "";
if(precentComplete <= .3)
{
style = "background-color:red";
}
else if(precentComplete <= .7)
{
style = "background-color:orange";
}
element[0].getElementsByClassName('jqx-kanban-item-color-status')[0].style = style;
if(!resource.common)
element[0].getElementsByClassName('jqx-kanban-item-avatar-image')[0].src = this.props.webUrl + "/_layouts/15/userphoto.aspx?size=M&username=" + resource.email;
};
this.state = {
template: template,
itemRenderer,
width: "100%",
listTitle: this.props.listTitle,
showBoard: false,
noItems: true,
taskDetails: {},
hideDialog:true
};
}
public render(): React.ReactElement<IKanbanBoardProps> {
const el = this.state.showBoard ?
!this.state.noItems ? <JqxKanban
width={this.state.width}
height={"100%"}
source={this.state.source}
columns={this.state.columns}
resources={this.state.resources}
onItemMoved={this._updateTask}
itemRenderer={this.state.itemRenderer}
template={this.state.template}
onItemAttrClicked={this._showTask}
/>: <div>No tasks found!</div> : <Spinner label="Loading tasks..." ariaLive="assertive" labelPosition="top" />;
const selectlist = !(this.state.listTitle && this.state.listTitle.length > 0) ? <div>Please choose a list</div> : null;
return (<>
{selectlist}
{el}
<Dialog
minWidth="600"
hidden={this.state.hideDialog}
onDismiss={this._closeDialog}
dialogContentProps={{
type: DialogType.largeHeader,
title: this.state.taskDetails.text,
subText: ''
}}
modalProps={{
isBlocking: false,
styles: { main: { minWidth:600 } }
}}
>
<Stack>
<Stack horizontal horizontalAlign="stretch">
<Stack.Item align="auto" styles={rowStyle1}>
<span>% Complete</span>
</Stack.Item>
<Stack.Item align="stretch" styles={rowStyle2}>
<span>{ parseFloat(this.state.taskDetails.color) * 100 }</span>
</Stack.Item>
</Stack>
<Stack horizontal horizontalAlign="stretch">
<Stack.Item align="auto" styles={rowStyle1}>
<span>Description</span>
</Stack.Item>
<Stack.Item align="stretch" styles={rowStyle2}>
<span>{ ReactHtmlParser(this.state.taskDetails.content) }</span>
</Stack.Item>
</Stack>
<Stack horizontal>
<Stack.Item align="auto" styles={rowStyle1}>
<span>Priority</span>
</Stack.Item>
<Stack.Item align="stretch" styles={rowStyle2}>
<span>{ this.state.taskDetails.tags }</span>
</Stack.Item>
</Stack>
<Stack horizontal>
<Stack.Item align="auto" styles={rowStyle1}>
<span>Task Status</span>
</Stack.Item>
<Stack.Item align="stretch" styles={rowStyle2}>
<span>{ this.state.taskDetails.status }</span>
</Stack.Item>
</Stack>
</Stack>
<DialogFooter>
<DefaultButton onClick={this._closeDialog} text="Close" />
</DialogFooter>
</Dialog>
</>);
}
public static getDerivedStateFromProps(nextProps, prevState){
if(nextProps.listTitle!==prevState.listTitle){
return { listTitle: nextProps.listTitle};
}
else return null;
}
public componentDidUpdate(prevProps, prevState) {
if (prevState.listTitle !== this.state.listTitle) {
this.setState({ listTitle: this.props.listTitle,showBoard:false });
this._getData(this.props.listTitle);
}
}
public componentDidMount(){
this._getData(this.props.listTitle);
}
private _getData = (listTitle) => {
if(listTitle && listTitle.length > 0)
{
sp.web.lists.getByTitle(listTitle).fields.getByInternalNameOrTitle("Status").get()
.then(status => {
const cols = status.Choices.map((val,index) => {
return { text: val, dataField: val };
});
sp.web.lists.getByTitle(listTitle).items.getAll().then(res => {
const source = {
dataFields: this.sourceFields,
dataType: 'array',
localData: [ ...res ]
};
sp.web.siteUsers.get().then(users => {
const resourcesAdapterFunc = (): any => {
const resourcesSource = {
dataFields: this.resourceFields,
dataType: 'array',
localData: [...users]
};
const resourcesDataAdapter = new jqx.dataAdapter(resourcesSource);
return resourcesDataAdapter;
};
this.setState({
width: "100%",
columns: cols,
resources: resourcesAdapterFunc(),
source: new jqx.dataAdapter(source),
showBoard: true,
noItems: source.localData.length <= 0
});
});
});
});
} else {
this.setState({
showBoard:true
});
}
}
private _updateTask = (event: any): void => {
let args = event.args;
// let itemId = args.itemId;
// let oldParentId = args.oldParentId;
// let newParentId = args.newParentId;
// let itemData = args.itemData;
// let oldColumn = args.oldColumn;
// let newColumn = args.newColumn;
sp.web.lists.getByTitle(this.props.listTitle).items.getById(args.itemId).update({
Status: args.newColumn.dataField
}).then(res => {
console.log("Task updated");
});
}
private _showTask = (event:any): void => {
if(event.args.attribute === "template")
{
this.setState({
taskDetails : {
...event.args.item
},
hideDialog: false
});
}
}
private _closeDialog = (): void => {
this.setState({ hideDialog: true });
}
}

View File

@ -0,0 +1,4 @@
.ordercolor {
width: 10px;
height: 10px;
}

View File

@ -0,0 +1,173 @@
import * as React from 'react';
import * as strings from 'KanbanBoardWebPartStrings';
import { DisplayMode, Guid, Environment, EnvironmentType } from '@microsoft/sp-core-library';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { findIndex, isEqual, cloneDeep } from '@microsoft/sp-lodash-subset';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import {KanbanComponent,IKanbanBucket,IKanbanTask} from '../../../kanban';
import { mergeBucketsWithChoices } from './helper';
import { ISPKanbanService } from '../services/ISPKanbanService';
import SPKanbanService from '../services/SPKanbanService';
import MockKanbanService from '../services/MockKanbanService';
export interface IKanbanBoardV2Props {
hideWPTitle: boolean;
title: string;
displayMode: DisplayMode;
updateProperty: (value: string) => void;
context: WebPartContext;
listId: string;
configuredBuckets: IKanbanBucket[]; // need mearge with current readed
statekey: string; // force refresh ;)
}
export interface IKanbanBoardV2State {
loading: boolean;
isConfigured: boolean;
buckets: IKanbanBucket[];
tasks: IKanbanTask[];
errorMessage?: string;
}
export default class KanbanBoardV2 extends React.Component<IKanbanBoardV2Props, IKanbanBoardV2State> {
private choices: string[] = [];
private dataService: ISPKanbanService;
constructor(props: IKanbanBoardV2Props) {
super(props);
this.state = {
loading: false,
isConfigured: false,
buckets: [],
tasks: []
};
}
public componentDidMount(): void {
if (Environment.type == EnvironmentType.Local || Environment.type == EnvironmentType.Test) {
this.dataService= new MockKanbanService();
} else {
this.dataService = new SPKanbanService();
}
this._getData();
}
public shouldComponentUpdate(nextProps: IKanbanBoardV2Props, nextState: IKanbanBoardV2State): boolean {
if (!isEqual(this.state, nextState)) { return true; }
if (!isEqual(this.props, nextProps)) {
//stateKey
return true;
}
return false;
}
public componentDidUpdate(prevProps: IKanbanBoardV2Props) {
if (this.props.listId !== prevProps.listId) {
this._getData();
}
const currentPropBuckets: IKanbanBucket[] = mergeBucketsWithChoices(this.props.configuredBuckets, this.choices);
if (!isEqual(this.state.buckets, currentPropBuckets)) {
this.setState({ buckets: cloneDeep(currentPropBuckets) });
}
}
public render(): React.ReactElement<IKanbanBoardV2Props> {
const { buckets, tasks, errorMessage } = this.state;
const { hideWPTitle, displayMode } = this.props;
const isConfigured: boolean = this.state.isConfigured;
const isLoading: boolean = this.state.loading;
return (
<div>
{!hideWPTitle && (<WebPartTitle displayMode={displayMode}
title={this.props.title}
updateProperty={this.props.updateProperty} />
)}
{!isConfigured && !isLoading && (<Placeholder iconName='Edit'
iconText={strings.PlaceholderIconText}
description={strings.PlaceholderDescription}
buttonLabel={strings.PlaceholderButtonLabel}
hideButton={displayMode === DisplayMode.Read}
onConfigure={this._onConfigure} />
)}
{isConfigured && isLoading && (
<Spinner label={strings.SpinnerLabel} ariaLive="assertive" labelPosition="top" />
)}
{isConfigured && !isLoading && (
<KanbanComponent
buckets={buckets}
tasks={tasks}
tasksettings={{
showPriority: true,
showAssignedTo: true,
showTaskDetailsButton: true
}
}
taskactions={{
moved: this._moved.bind(this),
}}
showCommandbar={false}
/>)
}
{!!errorMessage && (<div>{errorMessage}</div>)}
</div>
);
}
private _onConfigure = () => {
this.props.context.propertyPane.open();
}
private _moved(taskId: string, targetBucket: IKanbanBucket): void {
const elementsIndex = findIndex(this.state.tasks, element => element.taskId == taskId);
let newArray = [...this.state.tasks]; // same as Clone
newArray[elementsIndex].bucket = targetBucket.bucket;
this.dataService.updateTaskBucketMove(this.props.listId, +taskId, targetBucket.bucket)
.then(res => {
this.setState({ tasks: newArray });
}).catch(error => {
this.setState({ errorMessage: 'Error Update Task Item' });
});
}
private _getData(): void {
if (!this.props.listId || this.props.listId.length == 0) {
this.setState({ isConfigured: false, loading: false });
} else {
const listId: string = this.props.listId;
this.dataService.getBuckets(listId).then((choices) => {
this.choices = choices;
const currentbuckets: IKanbanBucket[] = mergeBucketsWithChoices(this.props.configuredBuckets, this.choices);
if (!currentbuckets) {
this.setState({ isConfigured: false, loading: false, errorMessage: 'No Buckets found' });
return;
}
this.dataService.getAllTasks(listId).then((tasks) => {
this.setState({
isConfigured: true,
loading: false,
errorMessage: undefined,
buckets: currentbuckets,
tasks: tasks
});
});
});
this.setState({ isConfigured: true, loading: true });
}
}
}

View File

@ -0,0 +1,82 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import {
IPropertyPaneField,
PropertyPaneFieldType
} from '@microsoft/sp-property-pane';
import {KanbanBucketConfigurator, IKanbanBucketConfiguratorProps } from '../../../kanban';
import { IKanbanBucket } from '../../../kanban/IKanbanBucket';
export interface IPropertyPaneBucketConfig {
key: string;
properties: IKanbanBucket;
onPropertyChange(propertyPath: string, oldValue: any, newValue: any): void;
}
export interface IPropertyPaneBucketConfigInternal extends IPropertyPaneBucketConfig {
targetProperty: string;
onRender(elem: HTMLElement, ctx, changeCallback): void;
onDispose(elem: HTMLElement): void;
onChanged(targetProperty: string, value: IKanbanBucket): void;
}
class PropertyPaneBucketConfigBuilder implements IPropertyPaneField<IPropertyPaneBucketConfigInternal> {
// Properties defined by IPropertyPaneField
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneBucketConfigInternal;
private elem: HTMLElement;
// Custom properties
private customProperties: any;
private onPropertyChange: (propertyPath: string, oldValue: any, newValue: any) => void;
public constructor(_targetProperty: string, _properties: IPropertyPaneBucketConfigInternal) {
this.customProperties = _properties.properties;
this.targetProperty = _targetProperty;
this.onPropertyChange = _properties.onPropertyChange;
this.properties = _properties;
this.properties.onRender = this.render.bind(this);
this.properties.onDispose = this.dispose;
}
private render(elem: HTMLElement, ctx?, changeCallback?: (targetProperty: string, value: any) => void): void {
if (!this.elem) {
this.elem = elem;
}
const configprops: IKanbanBucketConfiguratorProps = {
index: 1,
bucket: this.customProperties,
update: this.saveBucketConfig.bind(this)
};
const element: React.ReactElement<IKanbanBucketConfiguratorProps> = React.createElement(KanbanBucketConfigurator,
{ ...configprops }
);
ReactDom.render(element, elem);
}
private saveBucketConfig(index: number, value: IKanbanBucket): void {
if (this.onPropertyChange) {
this.onPropertyChange(this.targetProperty, this.customProperties, value);
}
}
private dispose(elem: HTMLElement): void { }
}
export default function PropertyPaneBucketConfigComponent(targetProperty: string, properties: IPropertyPaneBucketConfig):
IPropertyPaneField<IPropertyPaneBucketConfigInternal> {
var newProperties: IPropertyPaneBucketConfigInternal = {
key: properties.key,
properties: properties.properties,
targetProperty: targetProperty,
onPropertyChange: properties.onPropertyChange,
onDispose: null,
onRender: null,
onChanged: null
};
return new PropertyPaneBucketConfigBuilder(targetProperty, newProperties);
}

View File

@ -0,0 +1,12 @@
import * as React from 'react';
import { IKanbanBucket } from '../../../kanban';
import styles from './KanbanBoardV2.module.scss';
export const bucketOrder = (item:IKanbanBucket, index:number): JSX.Element => {
return (
<span>
{<span className={styles.ordercolor} style={{ backgroundColor: item.color?item.color:'none' }}></span>}
{item.bucketheadline?item.bucketheadline:item.bucket}
</span>
);
};

View File

@ -0,0 +1,29 @@
import { IKanbanBucket } from "../../../kanban";
export function mergeBucketsWithChoices(inB: IKanbanBucket[], choices: string[]): IKanbanBucket[] {
const currentbuckets: IKanbanBucket[] = [];
if (inB &&
inB.length > 0 &&
choices && choices.length > 0) {
inB.forEach((b) => {
if (choices.filter((c) => c === b.bucket).length === 1) {
currentbuckets.push(b);
}
});
return currentbuckets;
} else if (choices && choices.length) {
//Adding with default values
choices.forEach((x) => {
currentbuckets.push({
bucket: x,
bucketheadline: x,
percentageComplete: 0
});
});
return currentbuckets;
} else {
return undefined;
}
}

View File

@ -1,7 +1,22 @@
define([], function() { define([], function () {
return { return {
"PropertyPaneDescription": "Description", "PropertyPaneDescription": "Description",
"BasicGroupName": "Basic Configuration", "BasicGroupName": "Basic Configuration",
"ListTitleFieldLabel": "List Title"
"PercentComplete": "Percent Complete",
"propertyPaneHideWPHeadline": "Hide WebPart-Title?",
"propertyPaneHideWPHeadlineShow": "show",
"propertyPaneHideWPHeadlineHide": "hide",
"propertyPaneSelectList": "Select List",
"propertyPaneGroupNameOrderBuckets": "Order Buckets",
"propertyPaneLabelOrderBuckets": "",
"propertyPaneBucketConfiguration": "Bucket Configuration",
"PlaceholderIconText": "Configure your web part",
"PlaceholderDescription": "Please configure the web part.",
"PlaceholderButtonLabel": "Configure",
"SpinnerLabel": "Seriously, still loading..."
} }
}); });

View File

@ -1,7 +1,24 @@
declare interface IKanbanBoardWebPartStrings { declare interface IKanbanBoardWebPartStrings {
PropertyPaneDescription: string; PropertyPaneDescription: string;
BasicGroupName: string; BasicGroupName: string;
ListTitleFieldLabel: string;
//Resource is used for Translating percentage SP Field Title would be better
PercentComplete: string;
propertyPaneHideWPHeadline: string;
propertyPaneHideWPHeadlineShow: string;
propertyPaneHideWPHeadlineHide: string;
propertyPaneSelectList: string;
propertyPaneGroupNameOrderBuckets: string;
propertyPaneLabelOrderBuckets: string;
propertyPaneBucketConfiguration: string;
PlaceholderIconText: string;
PlaceholderDescription: string;
PlaceholderButtonLabel: string;
SpinnerLabel: string;
} }
declare module 'KanbanBoardWebPartStrings' { declare module 'KanbanBoardWebPartStrings' {

View File

@ -0,0 +1,8 @@
import { IKanbanTask } from "../../../kanban";
export interface ISPKanbanService {
updateTaskBucketMove(listId:string,taskId: number, bucket: string): Promise<boolean>;
getAllTasks(listId:string,): Promise<IKanbanTask[]>;
getBuckets(listId:string,): Promise<string[]>;
}

View File

@ -0,0 +1,61 @@
import { ISPKanbanService } from "./ISPKanbanService";
import "@pnp/polyfill-ie11";
import { sp } from '@pnp/sp';
import { IKanbanTask, KanbanTaskMamagedPropertyType } from "../../../kanban";
import * as strings from 'KanbanBoardWebPartStrings';
export default class MockKanbanService implements ISPKanbanService {
public updateTaskBucketMove(listid: string, taskId: number, bucket: string): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => resolve(true), 1000);
});
}
public getAllTasks(listId: string): Promise < IKanbanTask[] > {
const data=[1,2,3,4];
const tasks: IKanbanTask[] = data.map((x) => {
return {
taskId: 'tid' + x,
title: 'Title'+x,
htmlDescription: '<p>Body <b>Bold</b></p>',
assignedTo:
{
text: 'Person '+x
},
priority: 'Prio'+x,
bucket: 'Status'+x,
mamagedProperties: [
{
name: 'PercentComplete',
displayName: strings.PercentComplete,
type: KanbanTaskMamagedPropertyType.percent,
value: x/10 /* 10/20 30 .. percent */
}
]
};
});
return new Promise((resolve) => {
setTimeout(() => resolve(tasks), 1000);
});
}
public getBuckets(listId: string): Promise < string[] > {
return new Promise((resolve) => {
setTimeout(() => resolve([
'Status1',
'Status2',
'Status3',
'Status4',
'Status5',
]), 1000);
});
}
}

View File

@ -0,0 +1,59 @@
import { ISPKanbanService } from "./ISPKanbanService";
import "@pnp/polyfill-ie11";
import { sp } from '@pnp/sp';
import { IKanbanTask, KanbanTaskMamagedPropertyType } from "../../../kanban";
import * as strings from 'KanbanBoardWebPartStrings';
export default class SPKanbanService implements ISPKanbanService {
public updateTaskBucketMove(listid: string, taskId: number, bucket: string): Promise<boolean> {
return sp.web.lists.getById(listid).items.getById(+taskId).update({
Status: bucket
}).then(() => { return true; });
}
public getAllTasks(listId: string, ): Promise<IKanbanTask[]> {
const odatafiels: string[] = ['AssignedTo/Id', 'AssignedTo/Title', 'AssignedTo/Name', 'AssignedTo/EMail',
'ID', 'Title', 'Status', 'Priority', 'PercentComplete', 'Body'
];
return sp.web.lists.getById(listId).items
.select(odatafiels.join(','))
.expand('AssignedTo').getAll().then(res => {
const tasks: IKanbanTask[] = res.map((x) => {
return {
taskId: '' + x.ID,
title: x.Title,
htmlDescription: x.Body,
assignedTo: (x.AssignedTo && (x.AssignedTo).length === 1) ?
{
text: x.AssignedTo[0].Title
}
: undefined,
priority: x.Priority,
bucket: x.Status,
mamagedProperties: [
{
name: 'PercentComplete',
displayName: strings.PercentComplete,
type: KanbanTaskMamagedPropertyType.percent,
value: x.PercentComplete
}
]
};
});
return tasks;
});
}
public getBuckets(listId: string, ): Promise<string[]> {
return sp.web.lists.getById(listId).fields.getByInternalNameOrTitle("Status").get()
.then(status => status.Choices.map((val, index) => {
return val;
}));
}
}

View File

@ -1,5 +1,5 @@
{ {
"extends": "./node_modules/@microsoft/rush-stack-compiler-2.9/includes/tsconfig-web.json", "extends": "node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
@ -29,7 +29,7 @@
] ]
}, },
"include": [ "include": [
"src/**/*.ts" "src/**/*.ts", "src/webparts/kanbanBoard/components/bucketOrder.tsx"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",