Skip to content

Commit 580fdd4

Browse files
committed
Release 2.0.0 - Adding Fluent-UI Variant
1 parent ed5960b commit 580fdd4

39 files changed

+6966
-7
lines changed
File renamed without changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<manifest>
3+
<control namespace="tc" constructor="GroupApprovalViewerFluent" version="1.0.0" display-name-key="Group Approval Viewer (Fluent)" description-key="Provides visibility into status of Power Automate group approvals" control-type="standard" preview-image="img/preview.png">
4+
<property name="ApprovalId" display-name-key="Approval Id" description-key="Power Automate Approval Lookup" of-type="Lookup.Simple" usage="bound" required="true" />
5+
<property name="ViewerTitle" display-name-key="Viewer Title" description-key="Please provide a title for your approval grid" default-value="APPROVAL PROGRESS SUMMARY" of-type="SingleLine.Text" usage="input" required="true" />
6+
<resources>
7+
<code path="index.ts" order="1"/>
8+
<css path="css/styles.css" order="1" />
9+
</resources>
10+
<feature-usage>
11+
<uses-feature name="WebAPI" required="true" />
12+
</feature-usage>
13+
</control>
14+
</manifest>
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import * as React from 'react';
2+
import { useState, useEffect } from 'react';
3+
import { IInputs } from "../generated/ManifestTypes";
4+
import { Fabric } from 'office-ui-fabric-react/lib/Fabric';
5+
import { TextField } from 'office-ui-fabric-react/lib/TextField';
6+
import { SelectionMode, IColumn } from 'office-ui-fabric-react/lib/DetailsList';
7+
import { ShimmeredDetailsList, } from 'office-ui-fabric-react/lib/ShimmeredDetailsList';
8+
import { Icon } from '@fluentui/react/lib/Icon';
9+
10+
export interface IResponseBag {
11+
boundLookupId: string | undefined,
12+
approvalLookupSchemaName: string | undefined,
13+
requests: ComponentFramework.WebApi.Entity[],
14+
responses: ComponentFramework.WebApi.Entity[]
15+
}
16+
17+
export interface ITableRow {
18+
key: string,
19+
name: string | undefined,
20+
status: JSX.Element | undefined,
21+
comments: string | undefined,
22+
}
23+
24+
export const Approvals = (_context: ComponentFramework.Context<IInputs>) => {
25+
26+
// ### SLICES
27+
const [ approvalState, setApprovalState ] = useState('');
28+
const [ isLoadingState, setIsLoadingState ] = useState(true);
29+
const [ tableDataState, setTableDataState ] = useState([] as ITableRow[]);
30+
const [ tableDataMasterState, setTableDataMasterState ] = useState([] as ITableRow[]);
31+
const [ columnState, setColumnState ] = useState([
32+
{ //name
33+
key: 'col1',
34+
name: 'Name',
35+
fieldName: 'name',
36+
minWidth: 210,
37+
maxWidth: 350,
38+
isRowHeader: true,
39+
isResizable: true,
40+
isSorted: false,
41+
isSortedDescending: false,
42+
sortAscendingAriaLabel: 'Sorted A to Z',
43+
sortDescendingAriaLabel: 'Sorted Z to A',
44+
data: 'string',
45+
isPadded: true,
46+
},
47+
{ //status
48+
key: 'col2',
49+
name: 'Status',
50+
fieldName: 'status',
51+
minWidth: 100,
52+
maxWidth: 150,
53+
isRowHeader: true,
54+
isResizable: false,
55+
data: 'string',
56+
isPadded: true,
57+
},
58+
{ //comments
59+
key: 'col3',
60+
name: 'Comments',
61+
fieldName: 'comments',
62+
minWidth: 210,
63+
maxWidth: 350,
64+
isMultiline: true,
65+
isRowHeader: true,
66+
isResizable: true,
67+
isSorted: false,
68+
isSortedDescending: false,
69+
sortAscendingAriaLabel: 'Sorted A to Z',
70+
sortDescendingAriaLabel: 'Sorted Z to A',
71+
data: 'string',
72+
isPadded: true,
73+
},
74+
] as IColumn[]);
75+
76+
77+
// ### LIFECYCLE
78+
useEffect(() => {
79+
init();
80+
}, []);
81+
82+
83+
// ### VARS
84+
// @ts-ignore
85+
const recordGuid:string = _context.page.entityId;
86+
//@ts-ignore
87+
const entityType:string = _context.page.entityTypeName;
88+
const lookupSchemaName:string | undefined = _context.parameters.ApprovalId.attributes?.LogicalName;
89+
const gridTitle:string = (_context.parameters.ViewerTitle.raw!.length !== 0)
90+
? `${_context.parameters.ViewerTitle.raw as string} (${approvalState})`
91+
: `APPROVAL PROGRESS SUMMARY (${approvalState})`;
92+
let approvalBag: IResponseBag = {
93+
boundLookupId: '',
94+
approvalLookupSchemaName: lookupSchemaName,
95+
requests: [],
96+
responses: []
97+
};
98+
99+
100+
// ### METHODS
101+
const init = async () => {
102+
103+
//getBoundRecordDetails
104+
await _context.webAPI.retrieveRecord(entityType, recordGuid, `?$select=_${approvalBag.approvalLookupSchemaName}_value`).then(
105+
function success(result) {
106+
approvalBag.boundLookupId = result[`_${lookupSchemaName}_value`];
107+
},
108+
function error(error) {
109+
setIsLoadingState(false);
110+
console.error('Group Approval Viewer - Retrieve Bound Lookup Error', error);
111+
}
112+
);
113+
114+
if (!approvalBag.boundLookupId) {
115+
setApprovalState('No Approval Assigned');
116+
setIsLoadingState(false);
117+
return;
118+
};
119+
120+
//getApprovalRequests & setApprovalState
121+
await _context.webAPI.retrieveMultipleRecords("msdyn_flow_approvalrequest",`?$filter=_msdyn_flow_approvalrequest_approval_value eq ${approvalBag.boundLookupId} &$select=_ownerid_value,createdon&$expand=msdyn_flow_approvalrequest_approval($select=msdyn_flow_approval_result)`).then(
122+
function success(result) {
123+
approvalBag.requests = result.entities;
124+
if (approvalBag.requests.length !== 0) {
125+
const approvalResult:string | null = approvalBag.requests[0].msdyn_flow_approvalrequest_approval.msdyn_flow_approval_result;
126+
setApprovalState((approvalResult) ? approvalResult : 'In Progress');
127+
};
128+
},
129+
function error(error) {
130+
setIsLoadingState(false);
131+
console.error('Group Approval Viewer - Retrieve Approval Requests Error', error);
132+
}
133+
);
134+
135+
if (approvalBag.requests.length === 0 ) {
136+
setIsLoadingState(false);
137+
setApprovalState('No Requests Found');
138+
return;
139+
};
140+
141+
//getApprovalResponse
142+
await _context.webAPI.retrieveMultipleRecords("msdyn_flow_approvalresponse",`?$filter=_msdyn_flow_approvalresponse_approval_value eq ${approvalBag.boundLookupId} &$select=_ownerid_value,createdon,msdyn_flow_approvalresponse_response,msdyn_flow_approvalresponse_comments`).then(
143+
function success(result) {
144+
approvalBag.responses = result.entities;
145+
},
146+
function error(error) {
147+
setIsLoadingState(false);
148+
console.error('Group Approval Viewer - Retrieve Approval Responses Error', error);
149+
}
150+
);
151+
152+
transformLoadTableData();
153+
};
154+
155+
const transformLoadTableData = () => {
156+
const approvalGridArray:ITableRow[] = approvalBag.requests.map((e, index) => {
157+
return {
158+
key: index.toString(),
159+
name: e['[email protected]'],
160+
status: getStatusIcon(e['_ownerid_value'], approvalBag.responses),
161+
comments: getApproverComments(e['_ownerid_value'], approvalBag.responses)
162+
};
163+
});
164+
165+
setTableDataState(approvalGridArray);
166+
setTableDataMasterState(approvalGridArray);
167+
setIsLoadingState(false);
168+
};
169+
170+
const getStatusIcon = (
171+
guid:string,
172+
respArr: ComponentFramework.WebApi.Entity[]
173+
) : JSX.Element | undefined => {
174+
try {
175+
const responseItem = respArr.find((e) => e['_ownerid_value'] === guid );
176+
if (responseItem) {
177+
const responseState = responseItem!['msdyn_flow_approvalresponse_response'].toString().toLowerCase();
178+
switch (responseState) {
179+
case 'approve':
180+
return <Icon className='icon icon-approve' iconName="LikeSolid" />;
181+
case 'reject':
182+
return <Icon className='icon icon-reject' iconName="DislikeSolid" />;
183+
}
184+
} else {
185+
return <Icon className='icon' iconName="Clock" />
186+
};
187+
}
188+
catch(error) {
189+
console.error('Group Approval Viewer - Retrieve Status Icon Error', error);
190+
}
191+
};
192+
193+
const getApproverComments = (
194+
guid:string,
195+
respArr: ComponentFramework.WebApi.Entity[]
196+
) : string | undefined => {
197+
try {
198+
const responseItem = respArr.find((e) => e['_ownerid_value'] === guid);
199+
if (responseItem) {
200+
return responseItem!['msdyn_flow_approvalresponse_comments'].toString();
201+
} else {
202+
return '#N/A'
203+
};
204+
}
205+
catch(error) {
206+
console.error('Group Approval Viewer - Retrieve Approval Response Error', error);
207+
}
208+
};
209+
210+
const onSearchHandler = (
211+
e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>
212+
) : void => {
213+
const query: string = (e.target as HTMLInputElement).value;
214+
const itemsFiltered = tableDataState.filter(row => {
215+
return (
216+
row.name?.toLowerCase().includes(query) ||
217+
row.comments?.toLowerCase().includes(query)
218+
);
219+
});
220+
221+
setTableDataState(query ? itemsFiltered : tableDataMasterState);
222+
};
223+
224+
const onSortHandler = (
225+
ev?: React.MouseEvent<HTMLElement>,
226+
column?: IColumn
227+
) : void => {
228+
if (!!column) {
229+
const newColumns: IColumn[] = [ ...columnState ];
230+
const currColumn: IColumn = newColumns.filter(currColumn => column.key === currColumn.key)[0];
231+
newColumns.forEach((newCol: IColumn) => {
232+
if (newCol === currColumn) {
233+
currColumn.isSortedDescending = !currColumn.isSortedDescending;
234+
currColumn.isSorted = true;
235+
} else {
236+
newCol.isSorted = false;
237+
newCol.isSortedDescending = true;
238+
}
239+
});
240+
const sortedItems = copySort(tableDataState, currColumn.fieldName!, currColumn.isSortedDescending);
241+
setTableDataState(sortedItems);
242+
}
243+
};
244+
245+
const copySort = <T extends unknown> (
246+
items: T[],
247+
columnKey: string,
248+
isSortedDescending?: boolean
249+
): T[] => {
250+
const key = columnKey as keyof T;
251+
return items.slice(0).sort((a: T, b: T) => ((isSortedDescending ? a[key] < b[key] : a[key] > b[key]) ? 1 : -1));
252+
};
253+
254+
255+
// ### COMPONENT
256+
return (
257+
<Fabric>
258+
<div className='approval-head'>
259+
<h2>{gridTitle}</h2>
260+
<TextField
261+
placeholder="Search"
262+
className='search-input'
263+
onChange={onSearchHandler}
264+
/>
265+
</div>
266+
<ShimmeredDetailsList
267+
setKey="items"
268+
items={tableDataState}
269+
columns={columnState}
270+
selectionMode={SelectionMode.none}
271+
enableShimmer={isLoadingState}
272+
ariaLabelForShimmer="Please Wait"
273+
ariaLabelForGrid="Approval Response Data"
274+
isHeaderVisible={true}
275+
onColumnHeaderClick={onSortHandler}
276+
/>
277+
</Fabric>
278+
);
279+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
h2 {
2+
font-weight: normal;
3+
font-size: 1.21429rem;
4+
}
5+
6+
.approval-head {
7+
display: flex;
8+
flex-wrap: wrap;
9+
justify-content: space-between;
10+
align-Items: center;
11+
}
12+
13+
.icon {
14+
font-size: 20px;
15+
padding: 5px;
16+
}
17+
18+
.icon-approve {
19+
color: #008000;
20+
}
21+
22+
.icon-reject {
23+
color: #FF0000;
24+
}
25+
26+
.search-input {
27+
min-width: 35%;
28+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {IInputs, IOutputs} from "./generated/ManifestTypes";
2+
import * as React from 'react';
3+
import * as ReactDOM from 'react-dom';
4+
import { Approvals } from "./components/Approvals"
5+
6+
export class GroupApprovalViewerFluent implements ComponentFramework.StandardControl<IInputs, IOutputs> {
7+
8+
private _context: ComponentFramework.Context<IInputs>;
9+
private _container: HTMLDivElement;
10+
private _notifyOutputChanged: () => void;
11+
12+
constructor() {}
13+
14+
public init(
15+
context: ComponentFramework.Context<IInputs>,
16+
notifyOutputChanged: () => void,
17+
state: ComponentFramework.Dictionary,
18+
container:HTMLDivElement)
19+
{
20+
// ### initialization
21+
this._context = context;
22+
this._container = container;
23+
this._notifyOutputChanged = notifyOutputChanged;
24+
25+
// ### setup dom
26+
ReactDOM.render(React.createElement(Approvals, this._context), this._container);
27+
}
28+
29+
public updateView(context: ComponentFramework.Context<IInputs>): void
30+
{
31+
32+
}
33+
34+
public getOutputs(): IOutputs
35+
{
36+
return {};
37+
}
38+
39+
public destroy(): void
40+
{
41+
ReactDOM.unmountComponentAtNode(this._container);
42+
}
43+
}

0 commit comments

Comments
 (0)