diff --git a/.gitignore b/.gitignore index a611702..cae7da5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ **/generated # output directory -/out \ No newline at end of file +/out + +# msbuild output directories +/bin +/obj \ No newline at end of file diff --git a/README.md b/README.md index d12a1ad..c9c0231 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,66 @@ The control is styled to match the input fields on a Model-driven app. Also matches the hover effects ![YearPickerHover](https://github.com/delegateas/PCF/blob/master/images/YearPicker/yearpickerhover.png) + + +# TreeView +The tree view can be used to visualize a record and it's children (in a 1-N relationship). It has special support for attachments/annotations, enabling you to open or download the attachments with at double click. + +![TreeView](images/TreeView/TreeView.png) + +## Adding it to the form +To add the component to a form, simply add a field with the type `SingleLine.TextArea`. Then find the field properties and locate the final tab, called "Controls". +Here, you can add the TreeView control, and enable it for all devices. Marking it will reveal all the parameters (properties) you can set for the component. Here is a rundown: + + +| **PROPERTY** | **DESCRIPTION** | +|--|--| +| **Dummy Property** | Simply the field it bounds to on the form. You can leave this empty, it is simply used to get the context and use the webapi. | +| **Max height** | Leave empty to allow the component to expand, or enter a positive number, that sets a maximum height in pixels. | +| **Use mock data** | Used for testing, fills the tree with fake data that can't be clicked. Write `yes` to allow fake data. | +| **Download Attachments** | Double clicking an attachment/image will by default open the image or PDF in a new tab. If you wish to download the picture instead, write `yes` here. | +| **Relationship Map** | A JSON string that defines the relationship from the root entity and the children the tree view should display. | + +![TreeConfiguration](images/TreeView/TreeConfiguration.png) + +### The relationship map +This input is needed for the component to know what records to fetch, with everything it needs for the query. If you don't know JSON, have a read on [https://en.wikipedia.org/wiki/JSON](). + +A Relationship map could look like this: +```json +{ + "entityName" : "mvow_supplierassessment", + "titleField" : "mvow_name", + "children": [ + { + "entityName" : "mvow_assessment_question", + "titleField" : "mvow_name", + "parentLinkField" : "mvow_AssessmentLink", + "children": [ + { + "entityName" : "annotation", + "titleField" : "filename", + "parentLinkField" : "_objectid_value" + } + ] + } + ] +} +``` + +Every object (defined with {}) has the following attributes: + +| **ATTRIBUTE** | **DESCRIPTION** | +|--|--| +| entityName | The schema name of the entity to search for. | +| titleField | Schema name of the field on the entity that should be used as the label in the tree. | +| parentLinkField | _Optional_ - Needed on all children. Is used to find the records related to the parent. | +| children | _Optional_ A list of more relationship map objects. This is what defines what children will be shown in the tree view. | + +You can find schema names in any solution in the system. + +#### The Microsoft bug +Even though the type of the relationship supports many characters, the actual input field for the parameter has a cap of 100. This is only set client-side and can be changed in the DOM. **You need to do this every time you change the relationship map**. +How to do it can be seen in the following video: + +![TreeViewConfigBug](images/TreeView/TreeViewConfigBug.gif) \ No newline at end of file diff --git a/Solution/.gitignore b/Solution/.gitignore new file mode 100644 index 0000000..317e653 --- /dev/null +++ b/Solution/.gitignore @@ -0,0 +1,5 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# msbuild output directories +/bin +/obj \ No newline at end of file diff --git a/Solution/Other/Customizations.xml b/Solution/Other/Customizations.xml new file mode 100644 index 0000000..9716685 --- /dev/null +++ b/Solution/Other/Customizations.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + 1033 + + \ No newline at end of file diff --git a/Solution/Other/Relationships.xml b/Solution/Other/Relationships.xml new file mode 100644 index 0000000..c22a38e --- /dev/null +++ b/Solution/Other/Relationships.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Solution/Other/Solution.xml b/Solution/Other/Solution.xml new file mode 100644 index 0000000..bc3e78a --- /dev/null +++ b/Solution/Other/Solution.xml @@ -0,0 +1,94 @@ + + + + + DelegatePCF + + + + + + 1.0 + + 2 + + + Delegate + + + + + + + + + + + + dg + + 54677 + + +
+ 1 + 1 + + + + + + + + + + + + + + + + 1 + + + + + + + + +
+
+ 2 + 1 + + + + + + + + + + + + + + + + 1 + + + + + + + + +
+
+
+ + +
+
\ No newline at end of file diff --git a/Solution/delegatepcf.cdsproj b/Solution/delegatepcf.cdsproj new file mode 100644 index 0000000..bdc8785 --- /dev/null +++ b/Solution/delegatepcf.cdsproj @@ -0,0 +1,48 @@ + + + + $(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps + + + + + + + 678feca6-7e92-4950-865b-5b844a3cf3b9 + v4.6.2 + + net462 + PackageReference + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TreeView/Components/TreeView.tsx b/TreeView/Components/TreeView.tsx new file mode 100644 index 0000000..7455b8e --- /dev/null +++ b/TreeView/Components/TreeView.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import {Tree, Input, Skeleton, Empty, Alert} from 'antd'; +import { ITreeNode } from '../Models/TreeNode'; +import { IFetcher } from '../Fetchers/IFetcher'; +import { AntTreeNode } from 'antd/lib/tree'; +const { TreeNode } = Tree; +const { Search } = Input; + +export interface ITreeViewProps{ + fetcher: IFetcher; + openWindow: (node: ITreeNode) => void; + maxHeight?: number; +} + +export function TreeWithSearch(props: ITreeViewProps){ + // STATES + const [isLoaded, setIsLoaded] = React.useState(false); + const [tree, setTree] = React.useState([]); + const [search, setSearch] = React.useState(''); + const [expandKeys, setExpandKeys] = React.useState([]); + const [autoExpandParent, setAutoExpandParent] = React.useState(true); + const [errorMsg, setErrorMsg] = React.useState(''); + + // EFFECTS (onload) + React.useEffect(() => { + let fetcher = props.fetcher; + fetcher.fetchTrees().then(v => { + setTree(v); + setExpandKeys(() => keysOfAllParents(v)); + setIsLoaded(true); + }).catch(e => { + setTree([]); + setErrorMsg(e); + setIsLoaded(true); + }); + }, [props.fetcher]); + + // FUNCTIONS + let keysOfAllParents = (t: ITreeNode[] | undefined): string[] => { + if(t === undefined) return[]; + let parents = t.filter(i => i.children !== undefined); + if (parents.length < 1) return []; + let childKeys: string[] = []; + parents.forEach(i => childKeys = childKeys.concat(keysOfAllParents(i.children))) + return childKeys.concat(parents.map(i => i.id)); + }; + + let onExpand = (expandedKeys: string[]) => { + setExpandKeys(expandedKeys); + setAutoExpandParent(false); + }; + + let findNameInTree = (n: string, t: ITreeNode[]): ITreeNode[] => { + let nodes = t.filter(i => i.title.toLowerCase().includes(n.toLowerCase())); + let childNodes: ITreeNode[] = []; + t.forEach(i => { + if(i.children) childNodes = childNodes.concat(findNameInTree(n, i.children)) + }); + return nodes.concat(childNodes); + }; + + let findFromIdInTree = (id: string, t: ITreeNode[]): ITreeNode | undefined => { + let nodes = t.filter(i => i.id === id); + if(nodes.length > 0) return nodes[0]; // There may be only one! + + let childNode: ITreeNode | undefined = undefined; + t.forEach(i => { + if(i.children) { + let tmpNode = findFromIdInTree(id, i.children); + if(tmpNode !== undefined) childNode = tmpNode; + } + }); + + return childNode; + }; + + let onSearch = (e) => { + let { value } = e.target; + let matchingNodes = findNameInTree(value, tree); + + if(matchingNodes.length < 1){ + setExpandKeys(keysOfAllParents(tree)); + setSearch(''); + return; + } + + let keys = matchingNodes.map(i => i.id); + setExpandKeys(keys); + setSearch(value); + }; + + let onDoubleClick = (e, n: AntTreeNode) => { + let id = n.props.eventKey; + if(id === undefined) return; + try { + let node = findFromIdInTree(id, tree); + if(node === undefined) return; + props.openWindow(node); + } catch (error) { + console.error(error); + } + }; + + let generateTree = (Tree: ITreeNode[]) => { + return Tree.map(node => { + let index = node.title.toLowerCase().indexOf(search.toLowerCase()); + let title = node.title.toLowerCase().includes(search.toLowerCase()) ? ( + + {node.title.substring(0, index)} + {search} + {node.title.substr(index + search.length)} + + ) : ( + {node.title} + ); + return ( + + {node.children !== undefined && generateTree(node.children)} + + ); + }); + }; + + // STYLE + let containerStyle: React.CSSProperties = {padding: '10px', textAlign: 'left'}; + containerStyle = props.maxHeight ? + { + ...containerStyle, + maxHeight: props.maxHeight, + display: 'flex', + flexDirection: 'column' + } : containerStyle; + + // RENDER + return( +
+ {errorMsg === '' || } + {isLoaded && } + {!isLoaded ? + : + tree.length < 1 ? + : + + {generateTree(tree)} + } +
+ ) +} \ No newline at end of file diff --git a/TreeView/ControlManifest.Input.xml b/TreeView/ControlManifest.Input.xml new file mode 100644 index 0000000..ba1eb17 --- /dev/null +++ b/TreeView/ControlManifest.Input.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TreeView/Fetchers/DynamicsFetcher.ts b/TreeView/Fetchers/DynamicsFetcher.ts new file mode 100644 index 0000000..8a6154c --- /dev/null +++ b/TreeView/Fetchers/DynamicsFetcher.ts @@ -0,0 +1,95 @@ +import { ITreeNode, IAnnotaionFields } from "../Models/TreeNode"; +import { IInputs } from "../generated/ManifestTypes"; +import { RelationshipMap } from "../Models/RelationshipMap"; +import { IFetcher } from "./IFetcher"; + +export class DynamicsFetcher implements IFetcher{ + public constructor( + private parentId: string, + private context: ComponentFramework.Context, + private relationshipMap: RelationshipMap){} + + private async fetchChild(parentId: string, parentName: string, relationMaps: RelationshipMap[]): Promise { + let childrenNodes = await Promise.all(relationMaps.map(async relationMap => { + if(relationMap.parentLinkField === undefined) throw new Error("Configuration error: A child is missing the parent link field."); + let isAnnotation = relationMap.entityName === 'annotation'; // Annotations are a special case + let query = isAnnotation ? + this.createAnnotationQueryString(parentId, parentName, relationMap.parentLinkField!, relationMap.titleField) : + this.createQueryString(parentId, parentName, relationMap.parentLinkField!, relationMap.titleField); + + let children = await this.context.webAPI.retrieveMultipleRecords(relationMap.entityName, query); + let treeNodes: ITreeNode[] = children.entities.map(e => { + let annotationField: IAnnotaionFields | undefined = !isAnnotation ? + undefined : + { + mimetype: e["mimetype"], + body: e["documentbody"], + filename: e["filename"] + }; + + return { + title: e[relationMap.titleField], + entityName: relationMap.entityName, + id: e[relationMap.entityName + "id"], // Noice + annotatoinFields: annotationField + }; + }); + + if(relationMap.children){ + treeNodes = await Promise.all(treeNodes.map(async t => { + let childsers = await this.fetchChild(t.id, relationMap.entityName, relationMap.children!); + return { + ...t, + children: childsers + }; + })); + } + return treeNodes; + })); + + let combinedArray: ITreeNode[] = []; + childrenNodes.forEach(i => combinedArray = combinedArray.concat(i)); + combinedArray.sort((a, b) => this.compareStrings(a.title, b.title)); + return Promise.resolve(combinedArray); + } + + private compareStrings(a: string, b: string): number { + if(a.toLowerCase() < b.toLowerCase()) return -1; + if(a.toLowerCase() > b.toLowerCase()) return 1; + return 0; + } + + private createAnnotationQueryString(parentId: string, parentName: string, parentLinkField: string, titleField: string){ + return `?$select=${titleField},mimetype,documentbody&$filter=${parentLinkField} eq '${parentId}'`; + } + + private createQueryString(parentId: string, parentName: string, parentLinkField: string, titleField: string): string { + return `?$select=${titleField}&$filter=${parentLinkField}/${parentName}id eq '${parentId}'`; + } + + public fetchTrees(): Promise { + return new Promise(async (resolve, rejects) => { + let parentEntity = await this.context.webAPI.retrieveRecord(this.relationshipMap.entityName, this.parentId); + let parentNode: ITreeNode = { + title: parentEntity[this.relationshipMap.titleField], + id: this.parentId, + entityName: this.relationshipMap.entityName + }; + if(!this.relationshipMap.children) { rejects("No children configured in map."); return; } + + try { + parentNode.children = await this.fetchChild( + parentNode.id, + this.relationshipMap.entityName, + this.relationshipMap.children! + ); + + if(parentNode.children.length < 1) resolve([]); + resolve([parentNode]); + } catch (error) { + console.error(error); + rejects(error instanceof Error ? `${error.name} - ${error.message}` : 'An error occured getting child information. See log.'); + } + }); + } +} \ No newline at end of file diff --git a/TreeView/Fetchers/IFetcher.ts b/TreeView/Fetchers/IFetcher.ts new file mode 100644 index 0000000..acb6b0b --- /dev/null +++ b/TreeView/Fetchers/IFetcher.ts @@ -0,0 +1,5 @@ +import { ITreeNode } from "../Models/TreeNode"; + +export interface IFetcher{ + fetchTrees(): Promise; +} \ No newline at end of file diff --git a/TreeView/Fetchers/MockFetcher.ts b/TreeView/Fetchers/MockFetcher.ts new file mode 100644 index 0000000..0dbfc10 --- /dev/null +++ b/TreeView/Fetchers/MockFetcher.ts @@ -0,0 +1,77 @@ +import { IFetcher } from "./IFetcher"; +import { ITreeNode } from "../Models/TreeNode"; + +export class MockFetcher implements IFetcher { + public fetchTrees(): Promise { + return new Promise((resolve, reject) => { + resolve([ + { + id: '1', + entityName: 'notimportant', + title: 'title', + children: [ + { + id: '11', + title: 'title11', + entityName: 'notimportant', + children: [ + { + id: '111', + entityName: 'notimportant', + title: 'title111' + }, + { + id: '112', + entityName: 'notimportant', + title: 'title112' + }, + { + id: '113', + entityName: 'notimportant', + title: 'title113' + }, + { + id: '114', + entityName: 'notimportant', + title: 'title114' + } + ] + }, + { + id: '12', + entityName: 'notimportant', + title: 'title12', + children: [ + { + id: '121', + entityName: 'notimportant', + title: 'title121' + }, + { + id: '122', + entityName: 'notimportant', + title: 'title122' + }, + { + id: '123', + entityName: 'notimportant', + title: 'title123' + } + ] + }, + { + id: '13', + entityName: 'notimportant', + title: 'title13' + } + ] + }, + { + id: '2', + entityName: 'notimportant', + title: 'title2' + } + ]); + }); + } +} \ No newline at end of file diff --git a/TreeView/Models/RelationshipMap.ts b/TreeView/Models/RelationshipMap.ts new file mode 100644 index 0000000..d391f7a --- /dev/null +++ b/TreeView/Models/RelationshipMap.ts @@ -0,0 +1,7 @@ +export interface RelationshipMap { + entityName: string; + titleField: string; + link?: string; + parentLinkField?: string; + children?: RelationshipMap[]; +} \ No newline at end of file diff --git a/TreeView/Models/TreeNode.ts b/TreeView/Models/TreeNode.ts new file mode 100644 index 0000000..92723bc --- /dev/null +++ b/TreeView/Models/TreeNode.ts @@ -0,0 +1,13 @@ +export interface ITreeNode{ + id: string; + entityName: string; + title: string; + children?: ITreeNode[]; + annotatoinFields?: IAnnotaionFields; +} + +export interface IAnnotaionFields{ + mimetype: string; + body: string; + filename: string; +} \ No newline at end of file diff --git a/TreeView/index.ts b/TreeView/index.ts new file mode 100644 index 0000000..31a387f --- /dev/null +++ b/TreeView/index.ts @@ -0,0 +1,107 @@ +import {IInputs, IOutputs} from "./generated/ManifestTypes"; +import { TreeWithSearch, ITreeViewProps } from "./Components/TreeView"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { DynamicsFetcher } from "./Fetchers/DynamicsFetcher"; +import { MockFetcher } from "./Fetchers/MockFetcher"; +import { RelationshipMap } from "./Models/RelationshipMap"; +import { Alert } from "antd"; +import { object } from "prop-types"; +import { ITreeNode } from "./Models/TreeNode"; + +export class TreeView implements ComponentFramework.StandardControl { + + private container: HTMLDivElement; + + /** + * Empty constructor. + */ + constructor() + { + + } + + /** + * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here. + * Data-set values are not initialized here, use updateView. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions. + * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously. + * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface. + * @param container If a control is marked control-type='standard', it will receive an empty div element within which it can render its content. + */ + public init(context: ComponentFramework.Context, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container:HTMLDivElement) + { + this.container = container; + } + + + /** + * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions + */ + public updateView(context: ComponentFramework.Context): void + { + try { + let {isMock, relationshipMap, maxHeight, downloadAttachments} = context.parameters; + let id: string = (context).page.entityId; + let mock = isMock.raw === 'yes' ? true : false; + let map: RelationshipMap = mock ? {entityName: '', titleField: ''} : JSON.parse(relationshipMap.raw!); + let fetcher = mock ? new MockFetcher() : new DynamicsFetcher(id, context, map); + + let props: ITreeViewProps = { + fetcher: fetcher, + openWindow: (node) => this.openRecord(context, node, (downloadAttachments.raw === 'yes')), + maxHeight: (maxHeight.raw || undefined) + } + ReactDOM.render(React.createElement(TreeWithSearch, props), this.container); + } catch (e) { + console.error(e); + let errormsg = "An unknown error occured, see log."; + ReactDOM.render(React.createElement(Alert, {message: errormsg, type: "error"}), this.container); + } + + } + + private openRecord(context: ComponentFramework.Context, node: ITreeNode, download?: boolean){ + if(node.entityName === 'annotation' && node.annotatoinFields !== undefined){ + let base64 = `data:${node.annotatoinFields.mimetype};base64,${node.annotatoinFields.body}`; + + if(download){ + fetch(base64).then(x => x.blob()).then(fileBlob => { + let url = window.URL.createObjectURL(fileBlob); + let downloadTag = document.createElement("a"); + downloadTag.href = url; + downloadTag.download = node.annotatoinFields!.filename; + downloadTag.click(); + }); + } + else{ + fetch(base64).then(x => x.blob()).then(fileBlob => { + let url = window.URL.createObjectURL(fileBlob); + context.navigation.openUrl(url); + }); + } + } + else{ + context.navigation.openForm({entityName: node.entityName, entityId: node.id, openInNewWindow: true}); + } + } + + /** + * It is called by the framework prior to a control receiving new data. + * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output” + */ + public getOutputs(): IOutputs + { + return {}; + } + + /** + * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup. + * i.e. cancelling any pending remote calls, removing listeners, etc. + */ + public destroy(): void + { + ReactDOM.unmountComponentAtNode(this.container); + } +} \ No newline at end of file diff --git a/YearPicker/ControlManifest.Input.xml b/YearPicker/ControlManifest.Input.xml index 9d1dc46..380b1c0 100644 --- a/YearPicker/ControlManifest.Input.xml +++ b/YearPicker/ControlManifest.Input.xml @@ -1,6 +1,6 @@ - +