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 @@
-
+