Skip to content

Commit 4851c5a

Browse files
authored
Merge pull request #2085 from NishkalankBezawada/feature/groupPicker
New Control - GroupPicker
2 parents 04f6806 + 0e89435 commit 4851c5a

53 files changed

Lines changed: 706 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
26.5 KB
Loading
26.3 KB
Loading
12 KB
Loading
12.2 KB
Loading
5.05 KB
Loading
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# GroupPicker control
2+
3+
This control allows you to select one or multiple Microsoft 365 groups and/or security groups using Microsoft Graph. Suggestions and selected items display a small icon indicating the group type.
4+
5+
Here is an example of the control:
6+
7+
![GroupPicker initial](../assets/GroupPicker.png)
8+
9+
`GroupPicker` single selection mode showing all groups:
10+
11+
![GroupPicker All](../assets/GroupPicker-All.png)
12+
13+
`GroupPicker` single selection mode showing M365 groups:
14+
15+
![GroupPicker M365](../assets/GroupPicker-M365.png)
16+
17+
`GroupPicker` single selection mode showing Security groups:
18+
19+
![GroupPicker Security](../assets/GroupPicker-Security.png)
20+
21+
`GroupPicker` multi-selection mode:
22+
23+
![GroupPicker multi selection](../assets/GroupPicker-MultiSelect.png)
24+
25+
## How to use this control in your solutions
26+
27+
- Check that you installed the `@pnp/spfx-controls-react` dependency. Check out the [getting started](../../#getting-started) page for more information about installing the dependency.
28+
- Import the control into your component:
29+
30+
```TypeScript
31+
import { GroupPicker } from "@pnp/spfx-controls-react/lib/GroupPicker";
32+
```
33+
34+
- Use the `GroupPicker` control in your code as follows:
35+
36+
```TypeScript
37+
<GroupPicker
38+
appcontext={this.props.context}
39+
label="Group Picker"
40+
itemLimit={3}
41+
selectedGroups={this.state.selectedGroups}
42+
multiSelect={true}
43+
onSelectedGroups={(tagList: ITag[]) => {
44+
this.setState({ selectedGroups: tagList });
45+
}}
46+
groupType="M365" //All, Security
47+
themeVariant={this.props.themeVariant}
48+
/>
49+
```
50+
51+
## Implementation
52+
53+
The `GroupPicker` control can be configured with the following properties:
54+
55+
| Property | Type | Required | Description |
56+
| ---- | ---- | ---- | ---- |
57+
| appcontext | BaseComponentContext | yes | The context object of the SPFx loaded webpart or customizer. |
58+
| selectedGroups | ITag[] | yes | Array with selected groups. |
59+
| itemLimit | number | no | Number of allowed selected items. |
60+
| multiSelect | boolean | no | Optional mode indicates if multi-choice selections is allowed. Default is `true`. |
61+
| label | string | no | Label of the picker. |
62+
| styles | IBasePickerStyles | no | Custom styles of the picker. |
63+
| themeVariant | IReadonlyTheme | no | Theme variant used for SharePoint/Teams theming. |
64+
| groupType | GroupTypeFilter | no | Filter groups by type. Allowed values: `"All"`, `"M365"`, `"Security"`. Default is `"All"`. |
65+
| onSelectedGroups | (tagsList: ITag[]) => void | yes | Callback with groups selected. |
66+
67+
## MSGraph Permissions required
68+
69+
This control requires Microsoft Graph permissions for reading groups. Grant at least one of the following:
70+
71+
- `Group.Read.All`
72+
- `Directory.Read.All`
73+
74+
![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/GroupPicker)

src/GroupPicker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./controls/groupPicker";

src/common/model/IGroup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface IGroup {
2+
id: string;
3+
displayName: string;
4+
description?: string;
5+
groupTypes?: string[];
6+
mailEnabled?: boolean;
7+
mail?: string;
8+
securityEnabled?: boolean;
9+
visibility?: string;
10+
resourceProvisioningOptions?: string[];
11+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import * as React from "react";
2+
import {
3+
TagPicker,
4+
IBasePicker,
5+
ITag,
6+
IBasePickerSuggestionsProps,
7+
IPickerItemProps,
8+
ISuggestionItemProps,
9+
} from "@fluentui/react/lib/Pickers";
10+
11+
import { useGroups } from "../../hooks";
12+
import { IGroup } from "../../common/model/IGroup";
13+
import { IGroupPickerProps, GroupTypeFilter } from "./IGroupPickerProps";
14+
import { IGroupPickerState } from "./IGroupPickerState";
15+
import { useGroupPickerStyles } from "./GroupPickerStyles";
16+
import { IconButton } from "@fluentui/react/lib/Button";
17+
import { Text } from "@fluentui/react/lib/Text";
18+
import { Stack } from "@fluentui/react/lib/Stack";
19+
import { Label } from "@fluentui/react/lib/Label";
20+
import { FontIcon } from "@fluentui/react/lib/Icon";
21+
import { Customizer } from "@fluentui/react/lib/Utilities";
22+
23+
import pullAllBy from "lodash/pullAllBy";
24+
import find from "lodash/find";
25+
import strings from "ControlStrings";
26+
27+
const pickerSuggestionsProps: IBasePickerSuggestionsProps = {
28+
suggestionsHeaderText: strings.GroupPickerSuggestionsHeaderText,
29+
noResultsFoundText: strings.genericNoResultsFoundText,
30+
};
31+
32+
const initialState: IGroupPickerState = {
33+
savedSelectedGroups: [],
34+
};
35+
36+
const getTextFromItem = (item: ITag): string => item.name;
37+
38+
const matchGroupType = (
39+
group: IGroup,
40+
groupType?: GroupTypeFilter
41+
): boolean => {
42+
if (!groupType || groupType === "All") return true;
43+
44+
const isUnified = group.groupTypes?.includes("Unified");
45+
const isSecurity = !!group.securityEnabled && !isUnified;
46+
47+
if (groupType === "M365") return isUnified;
48+
if (groupType === "Security") return isSecurity;
49+
return true;
50+
};
51+
52+
const reducer = (
53+
state: IGroupPickerState,
54+
action: { type: string; payload: any } // eslint-disable-line @typescript-eslint/no-explicit-any
55+
): IGroupPickerState => {
56+
switch (action.type) {
57+
case "UPDATE_SELECTEDITEM":
58+
return { ...state, savedSelectedGroups: action.payload };
59+
default:
60+
return state;
61+
}
62+
};
63+
64+
export const GroupPicker: React.FunctionComponent<IGroupPickerProps> = (
65+
props: IGroupPickerProps
66+
) => {
67+
const [state, dispatch] = React.useReducer(reducer, initialState);
68+
const picker = React.useRef<IBasePicker<ITag>>(null);
69+
const { serviceScope } = props.appcontext;
70+
const { getGroups } = useGroups(serviceScope);
71+
const {
72+
onSelectedGroups,
73+
selectedGroups,
74+
itemLimit,
75+
multiSelect,
76+
label,
77+
styles,
78+
themeVariant,
79+
groupType,
80+
} = props;
81+
82+
const {
83+
pickerStylesMulti,
84+
pickerStylesSingle,
85+
renderItemStylesMulti,
86+
renderItemStylesSingle,
87+
renderIconButtonRemoveStyles,
88+
componentClasses,
89+
} = useGroupPickerStyles(themeVariant);
90+
91+
const groupTypeById = React.useRef<Record<string, "m365" | "security">>({});
92+
93+
const getGroupTypeIcon = React.useCallback(
94+
(groupId: string | number | undefined): JSX.Element | null => {
95+
if (!groupId) return null;
96+
const groupType = groupTypeById.current[groupId.toString()];
97+
if (!groupType) return null;
98+
if (groupType === "m365") {
99+
return (
100+
<FontIcon
101+
iconName="OfficeLogo"
102+
className={componentClasses.groupTypeIconM365}
103+
title={strings.GroupPickerGroupTypeM365Label}
104+
aria-label={strings.GroupPickerGroupTypeM365Label}
105+
/>
106+
);
107+
}
108+
return (
109+
<FontIcon
110+
iconName="LockSolid"
111+
className={componentClasses.groupTypeIconSecurity}
112+
title={strings.GroupPickerGroupTypeSecurityLabel}
113+
aria-label={strings.GroupPickerGroupTypeSecurityLabel}
114+
/>
115+
);
116+
},
117+
[componentClasses.groupTypeIconM365, componentClasses.groupTypeIconSecurity]
118+
);
119+
120+
const useFilterSuggestedGroups = React.useCallback(
121+
async (filterText: string, groupsList: ITag[]): Promise<ITag[]> => {
122+
const tags: ITag[] = [];
123+
try {
124+
const groups: IGroup[] = await getGroups(filterText);
125+
if (groups?.length) {
126+
for (const group of groups) {
127+
if (!matchGroupType(group, groupType)) continue;
128+
const isUnified = group.groupTypes?.includes("Unified");
129+
const groupTypeKey: "m365" | "security" | undefined = isUnified
130+
? "m365"
131+
: group.securityEnabled
132+
? "security"
133+
: undefined;
134+
if (groupTypeKey) {
135+
groupTypeById.current[group.id] = groupTypeKey;
136+
}
137+
const checkExists = find(groupsList, { key: group.id });
138+
if (checkExists) continue;
139+
tags.push({ key: group.id, name: group.displayName });
140+
}
141+
}
142+
return tags;
143+
} catch (error) {
144+
console.log(error);
145+
return tags;
146+
}
147+
},
148+
[groupType, getGroups]
149+
);
150+
151+
React.useEffect(() => {
152+
dispatch({
153+
type: "UPDATE_SELECTEDITEM",
154+
payload: selectedGroups,
155+
});
156+
}, [props]);
157+
158+
const _onRenderItem = React.useCallback(
159+
(itemProps: IPickerItemProps<ITag>) => {
160+
const { savedSelectedGroups } = state;
161+
if (itemProps.item) {
162+
return (
163+
<Stack
164+
horizontal
165+
horizontalAlign="start"
166+
verticalAlign="center"
167+
tokens={{ childrenGap: 7 }}
168+
styles={
169+
(multiSelect ?? true) && (itemLimit && itemLimit > 1)
170+
? renderItemStylesMulti
171+
: renderItemStylesSingle
172+
}
173+
>
174+
<FontIcon iconName="Group" />
175+
<Text variant="medium">{itemProps.item.name}</Text>
176+
{getGroupTypeIcon(itemProps.item.key)}
177+
<IconButton
178+
styles={renderIconButtonRemoveStyles}
179+
iconProps={{ iconName: "Cancel" }}
180+
title={strings.TeamPickerButtonRemoveTitle}
181+
onClick={() => {
182+
const _newSelectedGroups = pullAllBy(savedSelectedGroups, [
183+
itemProps.item,
184+
]);
185+
dispatch({
186+
type: "UPDATE_SELECTEDITEM",
187+
payload: _newSelectedGroups,
188+
});
189+
onSelectedGroups(_newSelectedGroups);
190+
}}
191+
/>
192+
</Stack>
193+
);
194+
}
195+
return null;
196+
},
197+
[
198+
state.savedSelectedGroups,
199+
renderItemStylesMulti,
200+
renderItemStylesSingle,
201+
renderIconButtonRemoveStyles,
202+
itemLimit,
203+
onSelectedGroups,
204+
]
205+
);
206+
207+
const _onRenderSuggestionsItem = React.useCallback(
208+
(propsTag: ITag, _itemProps: ISuggestionItemProps<ITag>) => {
209+
return (
210+
<Stack
211+
horizontal
212+
horizontalAlign="start"
213+
verticalAlign="center"
214+
tokens={{ childrenGap: 5, padding: 10 }}
215+
>
216+
<FontIcon iconName="Group" />
217+
<Text variant="smallPlus">{propsTag.name}</Text>
218+
{getGroupTypeIcon(propsTag.key)}
219+
</Stack>
220+
);
221+
},
222+
[]
223+
);
224+
225+
226+
return (
227+
<Customizer settings={{ theme: props.themeVariant }}>
228+
<div style={{ width: "100%" }}>
229+
{label && <Label>{label}</Label>}
230+
<TagPicker
231+
styles={
232+
styles ??
233+
((multiSelect ?? true) && (itemLimit && itemLimit > 1)
234+
? pickerStylesMulti
235+
: pickerStylesSingle)
236+
}
237+
selectedItems={state.savedSelectedGroups}
238+
onRenderItem={_onRenderItem}
239+
onRenderSuggestionsItem={_onRenderSuggestionsItem}
240+
onResolveSuggestions={useFilterSuggestedGroups}
241+
getTextFromItem={getTextFromItem}
242+
pickerSuggestionsProps={pickerSuggestionsProps}
243+
onEmptyResolveSuggestions={(selectGroups) => {
244+
return useFilterSuggestedGroups("", selectGroups);
245+
}}
246+
itemLimit={(multiSelect ?? true) ? (props.itemLimit ?? undefined) : 1}
247+
onChange={(items) => {
248+
dispatch({ type: "UPDATE_SELECTEDITEM", payload: items });
249+
props.onSelectedGroups(items);
250+
}}
251+
componentRef={picker}
252+
/>
253+
</div>
254+
</Customizer>
255+
);
256+
};

0 commit comments

Comments
 (0)