From d42997f9c6bb71cb895d3cddfef7201574021c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Mendes?= Date: Sat, 7 Oct 2023 22:01:55 +0100 Subject: [PATCH 01/60] commit --- src/controls/userPicker/IUserPickerProps.ts | 13 + src/controls/userPicker/PopUpMenu.tsx | 70 +++ src/controls/userPicker/User.tsx | 36 ++ src/controls/userPicker/UserPicker.tsx | 34 ++ src/controls/userPicker/UserPickerControl.tsx | 145 +++++++ src/controls/userPicker/atoms/globalState.ts | 8 + .../userPicker/constants/EAppHostname.ts | 6 + .../userPicker/constants/EMessageTypes.ts | 5 + .../userPicker/constants/constants.ts | 2 + .../userPicker/hooks/useGraphListAPI.ts | 162 +++++++ .../userPicker/hooks/useGraphUserAPI.ts | 151 +++++++ .../userPicker/hooks/useLocalStorage.ts | 40 ++ .../userPicker/hooks/useOnClickOutside.ts | 26 ++ src/controls/userPicker/hooks/useUtils.ts | 398 ++++++++++++++++++ src/controls/userPicker/models/IFileInfo.ts | 9 + .../userPicker/models/IGlobalState.ts | 10 + .../userPicker/models/IGraphBatchRequest.ts | 7 + src/controls/userPicker/models/IUserInfo.ts | 9 + .../userPicker/useSelectuserStyles.ts | 79 ++++ src/controls/userPicker/userCard/UserCard.tsx | 122 ++++++ .../userPicker/userCard/useUserCardStyles.ts | 51 +++ 21 files changed, 1383 insertions(+) create mode 100644 src/controls/userPicker/IUserPickerProps.ts create mode 100644 src/controls/userPicker/PopUpMenu.tsx create mode 100644 src/controls/userPicker/User.tsx create mode 100644 src/controls/userPicker/UserPicker.tsx create mode 100644 src/controls/userPicker/UserPickerControl.tsx create mode 100644 src/controls/userPicker/atoms/globalState.ts create mode 100644 src/controls/userPicker/constants/EAppHostname.ts create mode 100644 src/controls/userPicker/constants/EMessageTypes.ts create mode 100644 src/controls/userPicker/constants/constants.ts create mode 100644 src/controls/userPicker/hooks/useGraphListAPI.ts create mode 100644 src/controls/userPicker/hooks/useGraphUserAPI.ts create mode 100644 src/controls/userPicker/hooks/useLocalStorage.ts create mode 100644 src/controls/userPicker/hooks/useOnClickOutside.ts create mode 100644 src/controls/userPicker/hooks/useUtils.ts create mode 100644 src/controls/userPicker/models/IFileInfo.ts create mode 100644 src/controls/userPicker/models/IGlobalState.ts create mode 100644 src/controls/userPicker/models/IGraphBatchRequest.ts create mode 100644 src/controls/userPicker/models/IUserInfo.ts create mode 100644 src/controls/userPicker/useSelectuserStyles.ts create mode 100644 src/controls/userPicker/userCard/UserCard.tsx create mode 100644 src/controls/userPicker/userCard/useUserCardStyles.ts diff --git a/src/controls/userPicker/IUserPickerProps.ts b/src/controls/userPicker/IUserPickerProps.ts new file mode 100644 index 000000000..85e8638f3 --- /dev/null +++ b/src/controls/userPicker/IUserPickerProps.ts @@ -0,0 +1,13 @@ +import { IUserInfo } from './models/IUserInfo'; + +export interface IUserPickerProps { + userSelectionLimit?: number; + label?: string | JSX.Element; + required?: boolean; + validationMessage?: string; + messageType?: "error" | "success" | "warning" | "none" | undefined; + onSelectedUsers?: (users: IUserInfo[]) => void; + onRemoveSelectedUser?: (user: IUserInfo) => void; + placeholder?: string; + defaultSelectdUsers?: IUserInfo[]; +} diff --git a/src/controls/userPicker/PopUpMenu.tsx b/src/controls/userPicker/PopUpMenu.tsx new file mode 100644 index 000000000..ed22c0a5d --- /dev/null +++ b/src/controls/userPicker/PopUpMenu.tsx @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import * as React from 'react'; + +import { useAtom } from 'jotai'; +import { pullAllBy } from 'lodash'; + +import { Card } from '@fluentui/react-components'; +import { User as IUser } from '@microsoft/microsoft-graph-types'; + +import { globalState } from '../../atoms/globalState'; +import { useGraphUserAPI } from '../../hooks/useGraphUserAPI'; +import { useOnClickOutside } from '../../hooks/useOnClickOutside'; +import { IUserInfo } from '../../models/IUserInfo'; +import { UserCard } from '../userCard/UserCard'; +import { useSelectUserStyles } from './useSelectuserStyles'; + +interface IPopUpMenuProps { + isOpen: boolean; + searchValue: string; + onDismiss: (open?: boolean) => void; + target: React.RefObject; +} + +export const PopUpMenu = (props: IPopUpMenuProps): JSX.Element => { + const { searchValue, isOpen, onDismiss, target } = props; + const [appGlobalState, setAppGlobalState] = useAtom(globalState); + const { context, selectedUsers } = appGlobalState; + const [renderUsers, setRenderUsers] = React.useState([]); + const { getUserByName } = useGraphUserAPI(context); + const styles = useSelectUserStyles(); + + useOnClickOutside(true, target, () => onDismiss(false)); + + + const onSelected = React.useCallback((user: IUserInfo) => { + console.log(user); + setAppGlobalState({ ...appGlobalState, selectedUsers: [...selectedUsers, user] }); + onDismiss(false); + }, []); + + React.useEffect(() => { + setTimeout(async () => { + setRenderUsers([]); + const users: IUser[] = (await getUserByName(searchValue)) ?? []; + const usersToRender: JSX.Element[] = []; + const removeSelectedUsers = pullAllBy(users, selectedUsers, "mail"); + + for (const user of removeSelectedUsers) { + usersToRender.push( + <> + + + ); + } + setRenderUsers(usersToRender); + }, 500); + }, [searchValue, selectedUsers, getUserByName, onSelected]); + + if (renderUsers.length === 0 || !isOpen) return <>; + return ( + <> + {renderUsers} + + ); +}; diff --git a/src/controls/userPicker/User.tsx b/src/controls/userPicker/User.tsx new file mode 100644 index 000000000..2050baf84 --- /dev/null +++ b/src/controls/userPicker/User.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { Button } from '@fluentui/react-components'; +import { Dismiss16Regular } from '@fluentui/react-icons'; + +import { UserCard } from '../userCard/UserCard'; +import { useSelectUserStyles } from './useSelectuserStyles'; + +export interface IUserProps { + userId: string; + onRemove?: (userId: string) => void; +} + +export const User: React.FunctionComponent = (props: React.PropsWithChildren) => { + const { userId, onRemove } = props; + const styles = useSelectUserStyles(); + + const onClick = React.useCallback(() => { + if (onRemove) onRemove(userId); + }, [userId]); + + return ( + <> +
+ +
+ + ); +}; diff --git a/src/controls/userPicker/UserPicker.tsx b/src/controls/userPicker/UserPicker.tsx new file mode 100644 index 000000000..019c5b3e9 --- /dev/null +++ b/src/controls/userPicker/UserPicker.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { + Provider, + useAtom, +} from 'jotai'; + +import { FluentProvider } from '@fluentui/react-components'; + +import { globalState } from './atoms/globalState'; +import { IUserPickerProps } from './IUserPickerProps'; +import { UserPickerControl } from './UserPickerControl'; + +export const UserPIcker: React.FunctionComponent = ( + props: React.PropsWithChildren +) => { + const [appglobalState, setAppGlobalState] = useAtom(globalState); + const { theme } = appglobalState; + + + React.useEffect(() => { + setAppGlobalState({ ...appglobalState, ...props }); + }, []); + + return ( + <> + + + + + + + ); +}; diff --git a/src/controls/userPicker/UserPickerControl.tsx b/src/controls/userPicker/UserPickerControl.tsx new file mode 100644 index 000000000..fa343051f --- /dev/null +++ b/src/controls/userPicker/UserPickerControl.tsx @@ -0,0 +1,145 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as React from 'react'; + +import { useAtom } from 'jotai'; + +import { + Field, + Input, + PositioningImperativeRef, +} from '@fluentui/react-components'; + +import { globalState } from './atoms/globalState'; +import { IUserPickerProps } from './IUserPickerProps'; +import { PopUpMenu } from './PopUpMenu'; +import { User } from './User'; +import { useSelectUserStyles } from './useSelectuserStyles'; + +export const UserPickerControl: React.FunctionComponent = (props: IUserPickerProps) => { + const { userSelectionLimit, label, required, validationMessage, messageType, onSelectedUsers, onRemoveSelectedUser, defaultSelectdUsers } = props; + const buttonRef = React.useRef(null); + const positioningRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [appGlobalState, setAppGlobalState] = useAtom(globalState); + const { selectedUsers } = appGlobalState; + const [searchUser, setSearchUser] = React.useState(""); + const containerRef = React.useRef(null); + /* const forceUpdate = React.useReducer(() => ({}), {})[1] as () => void; */ + + const styles = useSelectUserStyles(); + console.log(styles); + + React.useEffect(() => { + if (buttonRef.current) { + positioningRef.current?.setTarget(buttonRef.current); + } + + }, [buttonRef, positioningRef]); + + React.useEffect(() => { + if (defaultSelectdUsers) { + setAppGlobalState({ ...appGlobalState, selectedUsers: defaultSelectdUsers }); + } + }, []); + + const hasSelectedUsers = React.useMemo(() => { + if (selectedUsers.length > 0){ + if ( onSelectedUsers) onSelectedUsers(selectedUsers); + return true; + } + }, [selectedUsers]); + + const showInput = React.useMemo(() => { + return userSelectionLimit ? selectedUsers.length < userSelectionLimit : true; + }, [selectedUsers, userSelectionLimit]); + + const onRemove = React.useCallback( + (userId: string) => { + const newUsers = selectedUsers.filter((user) => user.mail !== userId); + const removedUser = selectedUsers.filter((user) => user.mail === userId); + setAppGlobalState({ ...appGlobalState, selectedUsers: newUsers }); + onRemoveSelectedUser && onRemoveSelectedUser(removedUser[0]); + }, + + [selectedUsers] + ); + + const RenderSelectedUsers = React.useCallback((): JSX.Element => { + + return ( + <> + {selectedUsers.map((user) => { + return ( + <> +
+ +
+ + ); + })} + + ); + }, [selectedUsers]); + + return ( + <> +
+ +
+ {hasSelectedUsers ? ( + +
+ +
+ ) : null} + {showInput ? ( +
+ ) => { + console.log(event.target.value); + + if (event.target.value.length === 0) { + setSearchUser(""); + setOpen(false); + } else { + setSearchUser(event.target.value); + if (event.target.value.length >= 2) { + setOpen(true); + buttonRef.current?.focus(); + } + } + }} + /> + {open ? ( + { + setOpen(false); + setSearchUser(""); + }} + /> + ) : null} +
+ ) : null} +
+
+
+ + ); +}; diff --git a/src/controls/userPicker/atoms/globalState.ts b/src/controls/userPicker/atoms/globalState.ts new file mode 100644 index 000000000..f5664e312 --- /dev/null +++ b/src/controls/userPicker/atoms/globalState.ts @@ -0,0 +1,8 @@ +import { IGlobalState } from "../models/IGlobalState"; +import { IUserInfo } from "../models/IUserInfo"; +/* eslint-disable @typescript-eslint/no-var-requires */ +import { atom } from "jotai"; + +export const globalState = atom({ + selectedUsers: [] as IUserInfo[], +} as IGlobalState); diff --git a/src/controls/userPicker/constants/EAppHostname.ts b/src/controls/userPicker/constants/EAppHostname.ts new file mode 100644 index 000000000..308779623 --- /dev/null +++ b/src/controls/userPicker/constants/EAppHostname.ts @@ -0,0 +1,6 @@ +export enum EAppHostName { + SharePoint = "SharePoint", + Teams = "Teams", + Outlook = "Outlook", + Office = "Office", +} diff --git a/src/controls/userPicker/constants/EMessageTypes.ts b/src/controls/userPicker/constants/EMessageTypes.ts new file mode 100644 index 000000000..a7171e9db --- /dev/null +++ b/src/controls/userPicker/constants/EMessageTypes.ts @@ -0,0 +1,5 @@ +export enum EMessageType { + INFO = 'info', + ERROR = 'error', + SUCCESS = 'success', +} diff --git a/src/controls/userPicker/constants/constants.ts b/src/controls/userPicker/constants/constants.ts new file mode 100644 index 000000000..450bf3caa --- /dev/null +++ b/src/controls/userPicker/constants/constants.ts @@ -0,0 +1,2 @@ + +export const DEFAULT_AWARD_IMAGE = 'https://www.watermillaccounting.co.uk/wp-content/uploads/2016/10/awards.jpeg'; diff --git a/src/controls/userPicker/hooks/useGraphListAPI.ts b/src/controls/userPicker/hooks/useGraphListAPI.ts new file mode 100644 index 000000000..670bb8f05 --- /dev/null +++ b/src/controls/userPicker/hooks/useGraphListAPI.ts @@ -0,0 +1,162 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; + +import { BaseComponentContext } from '@microsoft/sp-component-base'; +import { MSGraphClientV3 } from '@microsoft/sp-http'; + +import { + ICategories, + ICategoryItem, +} from '../models/ICategoryItem'; +import { ILanguage } from '../models/ILanguage'; +import { IUserInfo } from '../models/IUserInfo'; +import { useGraphUserAPI } from './useGraphUserAPI'; +import { useCache } from './useLocalStorage'; + +const NOMINATION_CATEGORY_LIST = "NominationCategories"; +const NOMINATION_LIST = "UATNominations"; + +interface IuseGraphListAPI { + getCategories: (language: string) => Promise; + addNomination: ( + language: ILanguage, + category: ICategoryItem, + nomineeId: IUserInfo, + justifyNomination: string + ) => Promise; + checkIfListExists: (listName: string) => Promise; + checkIfListsExists: () => Promise; +} + +export const useGraphListAPI = (context: BaseComponentContext): IuseGraphListAPI => { + const { getCacheValue, setCacheValue } = useCache("local"); + const { getUserById } = useGraphUserAPI(context); + + const graphClient = React.useMemo(async (): Promise => { + if (!context) return undefined; + return await context.msGraphClientFactory.getClient("3"); + }, [context]); + + const selectCategoriesByLanguage = React.useCallback( + (categories: ICategories[], language: string): ICategoryItem[] => { + const selectedCategories: ICategoryItem[] = []; + for (let i = 0; i < categories.length; i++) { + const { fields } = categories[i]; + if (fields.field_5 === language) { + selectedCategories.push(fields); + } + } + return selectedCategories; + }, + [] + ); + + const getCategories = React.useCallback( + async (language: string): Promise => { + if (!graphClient || !language) return undefined; + + try { + const categoriesCached = getCacheValue("___nomination_categories___"); + if (categoriesCached) { + const selectcategoriesByLanguage = selectCategoriesByLanguage(categoriesCached, language); + return selectcategoriesByLanguage ?? undefined; + } + + const response: any = await (await graphClient) + ?.api(`https://graph.microsoft.com/v1.0/sites/root/lists/NominationCategories?expand=items(expand=fields)`) + + .get(); + if (response) { + setCacheValue("___nomination_categories___", response?.items ?? undefined); + const selectcategoriesByLanguage = selectCategoriesByLanguage(response?.items, language); + return selectcategoriesByLanguage ?? undefined; + } + } catch (error) { + console.log("[getCategories] error:", error); + throw new Error("Something went wrong when fetching categories"); + } + return undefined; + }, + [graphClient] + ); + + const addNomination = React.useCallback( + async ( + language: ILanguage, + category: ICategoryItem, + nomineeId: IUserInfo, + justifyNomination: string + ): Promise => { + if (!graphClient || !language || !category || !nomineeId) return Promise.reject(); + const nominee = await getUserById(nomineeId?.mail as string); + const submitter = await getUserById(context.pageContext.user.loginName); + const { Title } = category; + const { language: languageName } = language; + try { + const postPayload = { + fields: { + Title: Title, + field_1: languageName, + field_2: nominee?.displayName, + field_3: nominee?.country, + field_4: nominee?.displayName, + field_5: nominee?.department, + field_6: submitter?.displayName, + field_7: submitter?.department, + field_8: nominee?.jobTitle, + field_9: submitter?.jobTitle, + field_10: justifyNomination, + field_11: nominee?.mail, + field_12: submitter?.mail, + field_13: category?.id, + }, + }; + + const response: any = await (await graphClient) + ?.api(`https://graph.microsoft.com/v1.0/sites/root/lists/UATNominations/items`) + .post(postPayload); + if (response) { + return Promise.resolve(); + } + } catch (error) { + console.log("[addNomination] error:", error); + throw new Error("Something went wrong when adding nomination"); + return Promise.reject(); + } + return Promise.resolve(); + }, + [] + ); + + const checkIfListExists = React.useCallback( + async (listName: string): Promise => { + if (!graphClient || !listName) return false; + try { + const response: any = await (await graphClient) + ?.api(`https://graph.microsoft.com/v1.0/sites/root/lists/${listName}`) + .get(); + if (response) { + return true; + } + } catch (error) { + console.log("[getCategories] error:", error); + return false; + } + return false; + }, + [graphClient] + ); + + const checkIfListsExists = React.useCallback(async (): Promise => { + const [nominationCategoriesExists, nominationExists] = await Promise.all([ + checkIfListExists(NOMINATION_CATEGORY_LIST), + checkIfListExists(NOMINATION_LIST), + ]); + + const listsExists = nominationCategoriesExists && nominationExists; + return listsExists; + }, [checkIfListExists]); + + return { getCategories, addNomination, checkIfListExists, checkIfListsExists }; +}; diff --git a/src/controls/userPicker/hooks/useGraphUserAPI.ts b/src/controls/userPicker/hooks/useGraphUserAPI.ts new file mode 100644 index 000000000..bd5641010 --- /dev/null +++ b/src/controls/userPicker/hooks/useGraphUserAPI.ts @@ -0,0 +1,151 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; + +import { + Presence, + User, +} from '@microsoft/microsoft-graph-types'; +import { BaseComponentContext } from '@microsoft/sp-component-base'; + +import { IGraphBatchRequest } from '../models/IGraphBatchRequest'; +import { IUserInfo } from '../models/IUserInfo'; +import { useCache } from './useLocalStorage'; +import { useUtils } from './useUtils'; + +interface IuseGraphUserAPI { + getUserByName: (searchName: string) => Promise; + getUserById: (user: string) => Promise; + getUserPresence: (userObjectId: string) => Promise; +} + +export const useGraphUserAPI = (context: BaseComponentContext): IuseGraphUserAPI => { + const { getCacheValue, setCacheValue } = useCache("local"); + const { b64toBlob, blobToBase64 } = useUtils(); + + const graphClient = React.useMemo(async () => { + if (!context) return undefined; + return await context.msGraphClientFactory.getClient("3"); + }, [context]); + + const getUserByName = React.useCallback( + async (searchName: string): Promise => { + if (!graphClient || !searchName) return undefined; + + try { + const response: any = await (await graphClient) + ?.api( + `/users?$filter=startswith(displayName,'${searchName}') and accountEnabled eq true and userType eq 'Member'` + ) + .select("displayName,mail,jobTitle,department,officeLocation,preferredLanguage,accountEnabled,assignedLicenses,assignedPlans,usageLocation,userPrincipalName") + .get(); + if (response) { + return response?.value ?? undefined; + } + } catch (error) { + console.log("[getUserByName] error:", error); + throw new Error("Something went wrong when fetching user"); + } + return undefined; + }, + [graphClient] + ); + + const getUserById = React.useCallback( + async (user: string): Promise => { + let userInfo: IUserInfo | undefined; + let blobPhoto: string | undefined; + let usersResults: User | undefined; + + // Create a Batch Request + // 2 rquests + // id=1 = user Info + // id=2 = user Photo + + if (!graphClient) return undefined; + const batchRequests: IGraphBatchRequest[] = [ + { + id: "1", + url: `/users/${user}?$select=country,id, displayName,mail,jobTitle,department,officeLocation,preferredLanguage,accountEnabled,assignedLicenses,assignedPlans,usageLocation,userPrincipalName`, + method: "GET", + headers: { + ConsistencyLevel: "eventual", + }, + }, + { + id: "2", + url: `/users/${user}/photo/$value`, + headers: { "content-type": "img/jpg" }, + method: "GET", + }, + + ]; + + // Try to get user information from cache + try { + userInfo = await getCacheValue(`${user}`); + if (userInfo) { + return userInfo; + } + const batchResults: any = await (await graphClient) + ?.api(`/$batch`) + .version("v1.0") + .post({ requests: batchRequests }); + + // get Responses + const responses: any = batchResults?.responses; + // load responses + for (const response of responses) { + // user info + switch (response.id) { + case "1": + usersResults = response.body; + break; + case "2": + const binToBlob = response?.body ? await b64toBlob(response?.body, "img/jpg") : undefined; + blobPhoto = (await blobToBase64(binToBlob as any)) ?? undefined; + break; + + default: + break; + } + } + // save userinfo in cache + userInfo = { ...usersResults, userPhoto: blobPhoto as any, presence: undefined }; + // return Userinfo with photo + setCacheValue(`${user}`, userInfo); + return userInfo; + } catch (error) { + // execute batch + console.log("[getUserById] error:", error); + } + }, + [graphClient, getCacheValue] + ); + + + const getUserPresence = React.useCallback( + async (userObjectId: string): Promise => { + if (!graphClient || !userObjectId) return ; + + try { + const response: any = await (await graphClient) + ?.api( + `/users/${userObjectId}/presence` + ) + + .get(); + if (response) { + return response ?? undefined; + } + } catch (error) { + console.log("[getUserPresence] error:", error); + throw new Error("Something went wrong when getting user presence"); + } + return undefined; + }, + [graphClient] + ); + + return { getUserById, getUserByName, getUserPresence }; +}; diff --git a/src/controls/userPicker/hooks/useLocalStorage.ts b/src/controls/userPicker/hooks/useLocalStorage.ts new file mode 100644 index 000000000..c79d916c5 --- /dev/null +++ b/src/controls/userPicker/hooks/useLocalStorage.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import addSeconds from 'date-fns/addSeconds'; +import isAfter from 'date-fns/isAfter'; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +interface IStorage { + value: unknown; + expires?: Date; +} + +const DEFAULT_EXPIRED_IN_SECONDS = 60 * 30; // 30 min + +export const useCache = (cacheType: "local" | "session") => { + const setCacheValue = (key: string, newValue: unknown, expiredInSeconds?: number) => { + const expires = addSeconds(new Date(), expiredInSeconds ?? DEFAULT_EXPIRED_IN_SECONDS); + if (cacheType === "session") { + sessionStorage.setItem(key, JSON.stringify({ value: newValue, expires: expires.getTime() })); + } else { + localStorage.setItem(key, JSON.stringify({ value: newValue, expires: expires.getTime() })); + } + }; + const getCacheValue = (key: string): any => { + let storage: IStorage = {} as IStorage; + if (cacheType === "session") { + storage = JSON.parse(sessionStorage.getItem(key) || "{}"); + } else { + storage = JSON.parse(localStorage.getItem(key) || "{}"); + } + + // getting stored value + const { value, expires } = storage || ({} as IStorage); + if (expires && isAfter(new Date(expires as any), new Date())) { + return value; + } + return undefined; + }; + + return { getCacheValue, setCacheValue }; +}; diff --git a/src/controls/userPicker/hooks/useOnClickOutside.ts b/src/controls/userPicker/hooks/useOnClickOutside.ts new file mode 100644 index 000000000..295e13b7b --- /dev/null +++ b/src/controls/userPicker/hooks/useOnClickOutside.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + useCallback, + useEffect, +} from 'react'; + +export const useOnClickOutside = (active:boolean, ref: any, callback: any) => { + + const handleClickOutside = useCallback((event:any) => { + if (ref.current && !ref.current.contains(event.target) && active) { + callback(); + } +}, [ref, callback, active]); + + useEffect(() => { + // Bind the event listener + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("wheel", handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("wheel", handleClickOutside); + }; + }, [ref]); +} diff --git a/src/controls/userPicker/hooks/useUtils.ts b/src/controls/userPicker/hooks/useUtils.ts new file mode 100644 index 000000000..f4f28f678 --- /dev/null +++ b/src/controls/userPicker/hooks/useUtils.ts @@ -0,0 +1,398 @@ +/* eslint-disable @rushstack/security/no-unsafe-regexp */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import * as React from 'react'; + +export const DOCICONURL_XLSX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/xlsx.png"; +export const DOCICONURL_DOCX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/docx.png"; +export const DOCICONURL_PPTX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/pptx.png"; +export const DOCICONURL_MPPX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/mpp.png"; +export const DOCICONURL_PHOTO = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/photo.png"; +export const DOCICONURL_PDF = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/pdf.png"; +export const DOCICONURL_TXT = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/txt.png"; +export const DOCICONURL_EMAIL = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/email.png"; +export const DOCICONURL_CSV = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/csv.png"; +export const DOCICONURL_ONE = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/one.png"; +export const DOCICONURL_VSDX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/vsdx.png"; +export const DOCICONURL_VSSX = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/vssx.png"; +export const DOCICONURL_PUB = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/pub.png"; +export const DOCICONURL_ACCDB = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/accdb.png"; +export const DOCICONURL_ZIP = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/zip.png"; +export const DOCICONURL_GENERIC = + "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/genericfile.png"; +export const DOCICONURL_CODE = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/code.png"; +export const DOCICONURL_HTML = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/html.png"; +export const DOCICONURL_XML = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/xml.png"; +export const DOCICONURL_SPO = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/spo.png"; +export const DOCICONURL_VIDEO = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/video.png"; +export const DOCICONURL_AUDIO = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/audio.png"; +export const DOCICONURL_FOLDER = "https://static2.sharepointonline.com/files/fabric/assets/item-types/96/folder.png"; + +export const useUtils = () => { + + + + const getFileExtension = React.useCallback((fileName: string): string => { + if (!fileName) return ""; + const splitedName = fileName.split("."); + const fileExtension = splitedName[splitedName.length - 1]; + return fileExtension; + }, []); + /** + * GetFileImageUrl + */ + const GetFileImageUrl = React.useCallback((file: string): string => { + let _fileImageUrl: string = DOCICONURL_GENERIC; + const i = file.lastIndexOf("."); + const _fileTypes = i > -1 ? file.substring(i + 1, file.length) : ""; + const _fileExtension = _fileTypes.toLowerCase(); + if (!_fileExtension) { + return _fileImageUrl; + } + switch (_fileExtension.toLowerCase()) { + case "xlsx": + _fileImageUrl = DOCICONURL_XLSX; + break; + case "xls": + _fileImageUrl = DOCICONURL_XLSX; + break; + case "docx": + _fileImageUrl = DOCICONURL_DOCX; + break; + case "doc": + _fileImageUrl = DOCICONURL_DOCX; + break; + case "pptx": + _fileImageUrl = DOCICONURL_PPTX; + break; + case "ppt": + _fileImageUrl = DOCICONURL_PPTX; + break; + case "mppx": + _fileImageUrl = DOCICONURL_MPPX; + break; + case "mpp": + _fileImageUrl = DOCICONURL_MPPX; + break; + case "csv": + _fileImageUrl = DOCICONURL_CSV; + break; + case "pdf": + _fileImageUrl = DOCICONURL_PDF; + break; + case "txt": + _fileImageUrl = DOCICONURL_TXT; + break; + case "jpg": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "msg": + _fileImageUrl = DOCICONURL_EMAIL; + break; + case "jpeg": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "png": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "ico": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "gif": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "heic": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "tiff": + _fileImageUrl = DOCICONURL_PHOTO; + break; + case "eml": + _fileImageUrl = DOCICONURL_EMAIL; + break; + case "pub": + _fileImageUrl = DOCICONURL_PUB; + break; + case "accdb": + _fileImageUrl = DOCICONURL_ACCDB; + break; + case "zip": + _fileImageUrl = DOCICONURL_ZIP; + break; + case "7z": + _fileImageUrl = DOCICONURL_ZIP; + break; + case "tar": + _fileImageUrl = DOCICONURL_ZIP; + break; + case "js": + _fileImageUrl = DOCICONURL_CODE; + break; + case "json": + _fileImageUrl = DOCICONURL_CODE; + break; + case "html": + _fileImageUrl = DOCICONURL_HTML; + break; + case "xml": + _fileImageUrl = DOCICONURL_XML; + break; + case "aspx": + _fileImageUrl = DOCICONURL_SPO; + break; + case "mp4": + _fileImageUrl = DOCICONURL_VIDEO; + break; + case "mov": + _fileImageUrl = DOCICONURL_VIDEO; + break; + case "wmv": + _fileImageUrl = DOCICONURL_VIDEO; + break; + case "ogg": + _fileImageUrl = DOCICONURL_VIDEO; + break; + case "webm": + _fileImageUrl = DOCICONURL_VIDEO; + break; + default: + _fileImageUrl = DOCICONURL_GENERIC; + break; + } + return _fileImageUrl; + }, []); + + const getShortName = React.useCallback((name: string): string => { + if (!name) return ""; + const splitedName = name.split("."); + const displayCreatedFileName = splitedName[0].substring(0, 25); + const displayCreatedFileNameExt = splitedName[splitedName.length - 1]; + const displayCreatedFile = `${displayCreatedFileName}...${displayCreatedFileNameExt}`; + return displayCreatedFile; + }, []); + + const isOndrive = React.useCallback(async (name: string): Promise => { + if (!name) return false; + return name.indexOf("my.sharepoint.com") > -1; + }, []); + + const formatFileSize = React.useCallback((bytes: number, decimalPoint: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1000, + dm = decimalPoint || 2, + sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], + i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; + }, []); + + const getFolderIcon = React.useCallback((): string => { + return DOCICONURL_FOLDER; + }, []); + + const trimBeginDoubleSlash = React.useCallback((value: string) => { + if (value.charAt(0) === "/" && value.charAt(1) === "/") { + return value.substring(1, value.length); + } + return value; + }, []); + + const checkIfCursorIsInsideContainer = React.useCallback( + (event: React.MouseEvent, containerRef: HTMLDivElement): boolean => { + const containerRect = containerRef?.getBoundingClientRect(); + const mouseX = event.clientX; + const mouseY = event.clientY; + + if (containerRect) { + const isCursorInsideContainer = + mouseX >= containerRect.left && + mouseX <= containerRect.right && + mouseY >= containerRect.top && + mouseY <= containerRect.bottom; + + if (isCursorInsideContainer) { + // Do something when the cursor is inside the container + return true; + } else { + return false; + // Do something when the cursor is outside the container + } + } + return false; + }, + [] + ); + + const centerElement = React.useCallback((container: HTMLElement, elementToCenter: HTMLElement): { + left: number; + top: number; + } => { + const elementRect = elementToCenter.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const elementWidth = elementRect.width; + const elementHeight = elementRect.height; + + const windowWidth = containerRect.width; + const windowHeight = containerRect.height; + + const elementLeft = (windowWidth - elementWidth) / 2; + const elementTop = (windowHeight - elementHeight) / 2; + + return { left: elementLeft, top: elementTop }; + }, []); + + const hasValidMentionCharIndex = ( + mentionCharIndex: number, + text: string, + isolateChar?: string, + textPrefix?: string + ) => { + if (mentionCharIndex === -1) { + return false; + } + + if (!isolateChar) { + return true; + } + + const mentionPrefix = mentionCharIndex ? text[mentionCharIndex - 1] : textPrefix; + + return !mentionPrefix || !!mentionPrefix.match(/\s/); + }; + + const hasValidChars = (text: string, allowedChars: any) => { + return allowedChars.test(text); + }; + + const getMentionCharIndex = (text: string, mentionDenotationChars: any, isolateChar: string) => { + return mentionDenotationChars.reduce( + (prev: { mentionChar: any; mentionCharIndex: number }, mentionChar: string | any[]) => { + let mentionCharIndex; + + if (isolateChar) { + const regex = new RegExp(`^${mentionChar}|\\s${mentionChar}`, "g"); + const lastMatch = (text.match(regex) || []).pop(); + + if (!lastMatch) { + return { + mentionChar: prev.mentionChar, + mentionCharIndex: prev.mentionCharIndex, + }; + } + + mentionCharIndex = + lastMatch !== mentionChar ? text.lastIndexOf(lastMatch) + lastMatch.length - mentionChar.length : 0; + } else { + mentionCharIndex = text.lastIndexOf(mentionChar as string); + } + + if (mentionCharIndex > prev.mentionCharIndex) { + return { + mentionChar, + mentionCharIndex, + }; + } + return { + mentionChar: prev.mentionChar, + mentionCharIndex: prev.mentionCharIndex, + }; + }, + { mentionChar: null, mentionCharIndex: -1 } + ); + }; + + const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = reject; + reader.onload = (_) => { + resolve(reader.result as string); + }; + reader.readAsDataURL(blob); + }); + }; + + const getScrollPosition = (_dataListContainerRef: { + scrollTop: any; + scrollHeight: any; + clientHeight: any; + }): number => { + const { scrollTop, scrollHeight, clientHeight } = _dataListContainerRef; + const percentNow = (scrollTop / (scrollHeight - clientHeight)) * 100; + return percentNow; + }; + + const b64toBlob = async (b64Data: any, contentType: string, sliceSize?: number): Promise => { + contentType = contentType || "image/png"; + sliceSize = sliceSize || 512; + + const byteCharacters: string = atob(b64Data); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + const blob = new Blob(byteArrays, { type: contentType }); + return blob; + }; + + const getInitials = (name: string): string => { + if (!name) return ""; + const splitedName = name.split(" "); + let initials = ""; + if (splitedName.length > 1) { + initials = splitedName[0].charAt(0) + splitedName[1].charAt(0); + } else { + initials = splitedName[0].charAt(0); + } + return initials; + }; + + + const getBase64Image = (img: any) => { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + ctx?.drawImage(img, 0, 0); + const dataURL = canvas.toDataURL("image/png"); + return dataURL.replace(/^data:image\/(png|jpg);base64,/, ""); + }; + + const parseHtmlString = (htmlString: string) => { + const tmp = document.createElement("DIV"); + tmp.innerHTML = htmlString; + return tmp.textContent || tmp.innerText || ""; + }; + + return { + b64toBlob, + blobToBase64, + getMentionCharIndex, + hasValidChars, + hasValidMentionCharIndex, + + GetFileImageUrl, + getShortName, + isOndrive, + formatFileSize, + getFolderIcon, + trimBeginDoubleSlash, + checkIfCursorIsInsideContainer, + centerElement, + getScrollPosition, + getInitials, + getBase64Image, + parseHtmlString, + getFileExtension + }; +}; diff --git a/src/controls/userPicker/models/IFileInfo.ts b/src/controls/userPicker/models/IFileInfo.ts new file mode 100644 index 000000000..dace5f301 --- /dev/null +++ b/src/controls/userPicker/models/IFileInfo.ts @@ -0,0 +1,9 @@ +export interface FileInfo { + AbsoluteFileUrl: string; + ServerRelativeUrl: string; + FileExtension?: string; + Id: string; + ListId: string; + WebId: string; + SiteId: string; +} diff --git a/src/controls/userPicker/models/IGlobalState.ts b/src/controls/userPicker/models/IGlobalState.ts new file mode 100644 index 000000000..6af680854 --- /dev/null +++ b/src/controls/userPicker/models/IGlobalState.ts @@ -0,0 +1,10 @@ +import { Theme } from '@fluentui/react'; +import { BaseComponentContext } from '@microsoft/sp-component-base'; + +import { IUserInfo } from './IUserInfo'; + +export interface IGlobalState { + context: BaseComponentContext + theme: Theme | undefined + selectedUsers: IUserInfo[]; +} diff --git a/src/controls/userPicker/models/IGraphBatchRequest.ts b/src/controls/userPicker/models/IGraphBatchRequest.ts new file mode 100644 index 000000000..2ecbf871d --- /dev/null +++ b/src/controls/userPicker/models/IGraphBatchRequest.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface IGraphBatchRequest { + id: string; + method: string; + url: string; + headers?: any; +} diff --git a/src/controls/userPicker/models/IUserInfo.ts b/src/controls/userPicker/models/IUserInfo.ts new file mode 100644 index 000000000..b110b2de0 --- /dev/null +++ b/src/controls/userPicker/models/IUserInfo.ts @@ -0,0 +1,9 @@ +import { + Presence, + User, +} from '@microsoft/microsoft-graph-types'; + +export interface IUserInfo extends User { + userPhoto: string; + presence: Presence | undefined; +} diff --git a/src/controls/userPicker/useSelectuserStyles.ts b/src/controls/userPicker/useSelectuserStyles.ts new file mode 100644 index 000000000..d8d656bd6 --- /dev/null +++ b/src/controls/userPicker/useSelectuserStyles.ts @@ -0,0 +1,79 @@ +import { + makeStyles, + shorthands, + tokens, +} from '@fluentui/react-components'; + +export const useSelectUserStyles = makeStyles({ + container: { + display: "flex", + flexDirection: "row", + justifyContent: "start", + alignItems: "center", + + ...shorthands.gap("20px"), + ":hover": { + cursor: "pointer", + backgroundColor: tokens.colorNeutralBackground3, + }, + }, + image: { + width: "32px", + }, + imageButton: { + width: "28px", + }, + userItem: { + ...shorthands.padding("3px"), + minWidth: "200px", + maxWidth: "fit-content", + width: "100%", + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + ...shorthands.gap("20px"), + backgroundColor: tokens.colorNeutralBackground3, + /* ...shorthands.padding("3px"), */ + ...shorthands.border("1px solid", tokens.colorNeutralStroke1), + ...shorthands.borderRadius(tokens.borderRadiusCircular), + ...shorthands.overflow("hidden"), + }, + userItemCloseButton: { + ":hover": { + backgroundColor: tokens.colorNeutralBackground3Selected, + cursor: "pointer", + }, + }, + selectUserMainContainer: { + display: "flex", + flexDirection: "column", + rowGap: "5px", + ...shorthands.borderWidth("1px"), + ...shorthands.borderStyle("solid"), + ...shorthands.borderColor(tokens.colorNeutralStroke1), + }, + selectedUserContainer: { + display: "flex", + flexDirection: "row", + flexWrap: "wrap", + ...shorthands.padding("2px"), + columnGap: "2px", + rowGap: "5px", + marginTop: "3px", + marginBottom: "3px", + }, + inputContainer: { + width: "inherit", + }, + userCardStyles: { + ...shorthands.padding("5px"), + }, + popupContainer: { + position: "fixed", + zIndex: 99999, + minWidth: "250px", + ...shorthands.padding("10px"), + ...shorthands.gap("5px"), + }, +}); diff --git a/src/controls/userPicker/userCard/UserCard.tsx b/src/controls/userPicker/userCard/UserCard.tsx new file mode 100644 index 000000000..3217ba338 --- /dev/null +++ b/src/controls/userPicker/userCard/UserCard.tsx @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +//import './style.css'; + +import * as React from 'react'; + +import { useAtom } from 'jotai'; + +import { + mergeClasses, + Persona, + PresenceBadgeStatus, +} from '@fluentui/react-components'; +import { Presence } from '@microsoft/microsoft-graph-types'; +import { LivePersona } from '@pnp/spfx-controls-react/lib/LivePersona'; + +import { globalState } from '../../atoms/globalState'; +import { useGraphUserAPI } from '../../hooks/useGraphUserAPI'; +import { IUserInfo } from '../../models/IUserInfo'; +import { useUserCardStyles } from './useUserCardStyles'; + +/* import { MgtTemplateProps, Person } from "@microsoft/mgt-react"; +import { Caption1, Caption1Strong } from "@fluentui/react-components"; */ +export interface IuserCardProps { + userId?: string; + showOverCard?: boolean; + onSelected?: (user: IUserInfo) => void; + className?: string; +} + +export const UserCard: React.FunctionComponent = (props: React.PropsWithChildren) => { + const [appGlobalState] = useAtom(globalState); + const { context } = appGlobalState; + + const { userId, showOverCard, onSelected, className } = props; + const { getUserById, getUserPresence } = useGraphUserAPI(context); + const [user, setUser] = React.useState(); + const [isLoading, setIsLoading] = React.useState(true); + + const styles = useUserCardStyles(); + + const availability: PresenceBadgeStatus = React.useMemo(() => { + const { presence } = user || {}; + switch (presence?.availability?.toLowerCase()) { + case "available": + return "available"; + case "away": + return "away"; + case "busy": + return "busy"; + case "offline": + return "offline"; + default: + return "offline"; + } + }, [user?.presence?.availability]); + + const checkUserPresence = React.useCallback(async () => { + if (!userId) return; + let userPresence: Presence = { availability: "offline", activity: "offline" }; + let user = await getUserById(userId); + if (user) + try { + getUserPresence(user?.id || "").then((presence) => { + userPresence = presence as Presence; + user = { ...user, presence: userPresence } as IUserInfo; + setUser(user); + }); + } catch (error) { + console.log(error); + }finally{ + setIsLoading(false); + } + user = { ...user, presence: userPresence } as IUserInfo; + setUser(user); + }, [userId, context]); + + React.useEffect(() => { + (async () => { + await checkUserPresence(); + setInterval(async () => { + await checkUserPresence(); + }, 60000); + })(); + }, []); + + if (isLoading) return <>; + + return ( + <> +
{ + if (onSelected) onSelected(user as IUserInfo); + }} + > + +
+ +
+ + } + serviceScope={context.serviceScope as any} + /> +
+ + ); +}; diff --git a/src/controls/userPicker/userCard/useUserCardStyles.ts b/src/controls/userPicker/userCard/useUserCardStyles.ts new file mode 100644 index 000000000..c206de854 --- /dev/null +++ b/src/controls/userPicker/userCard/useUserCardStyles.ts @@ -0,0 +1,51 @@ +import { + makeStyles, + shorthands, + tokens, +} from '@fluentui/react-components'; + +export const useUserCardStyles = makeStyles({ + container: { + display: "flex", + flexDirection: "row", + justifyContent: "start", + alignItems: "center", + ...shorthands.gap("20px"), + ":hover": { + cursor: "pointer", + backgroundColor: tokens.colorNeutralBackground3, + }, + }, +root:{ + display: "flex", + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center", + width: "fit-content", + ...shorthands.gap("10px"), + }, + personLine1Container: { + display: "flex", + flexDirection: "row", + justifyContent: "start", + alignItems: "center", + width: "100%", + maxWidth: "100%", + ...shorthands.overflow("hidden"), + paddingBottom: "0px", + }, + personLine1: { + width: "100%", + maxWidth: "100%", + ...shorthands.overflow("hidden"), + display: "-webkit-box", + "-webkit-line-clamp": "1", + "-webkit-box-orient": "vertical", + paddingBottom: "0px", + textAlign: "start", + }, + personline1Styles: { + paddingRight: "5px", + color: tokens.colorNeutralForeground2 + }, +}); From 81e7a1192c6f95a7e70179008144b5dd78b9d234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Mendes?= Date: Sun, 8 Oct 2023 14:15:42 +0100 Subject: [PATCH 02/60] commit changes on UserPicker --- src/controls/userPicker/IUserPickerProps.ts | 9 + src/controls/userPicker/PopUpMenu.tsx | 26 ++- src/controls/userPicker/User.tsx | 11 +- src/controls/userPicker/UserPicker.tsx | 57 ++++-- src/controls/userPicker/UserPickerControl.tsx | 135 ++++++++------- .../userPicker/hooks/useGraphListAPI.ts | 162 ------------------ src/controls/userPicker/index.ts | 3 + .../userPicker/models/IGlobalState.ts | 8 +- ...ctuserStyles.ts => useUserPickerStyles.ts} | 2 +- src/controls/userPicker/userCard/NoUser.tsx | 20 +++ src/controls/userPicker/userCard/UserCard.tsx | 34 +++- .../controlsTest/ControlsTestWebPart.ts | 18 +- .../controlsTest/components/TestControl.tsx | 91 ++++++---- 13 files changed, 272 insertions(+), 304 deletions(-) delete mode 100644 src/controls/userPicker/hooks/useGraphListAPI.ts create mode 100644 src/controls/userPicker/index.ts rename src/controls/userPicker/{useSelectuserStyles.ts => useUserPickerStyles.ts} (97%) create mode 100644 src/controls/userPicker/userCard/NoUser.tsx diff --git a/src/controls/userPicker/IUserPickerProps.ts b/src/controls/userPicker/IUserPickerProps.ts index 85e8638f3..cacb4264b 100644 --- a/src/controls/userPicker/IUserPickerProps.ts +++ b/src/controls/userPicker/IUserPickerProps.ts @@ -1,3 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + BaseComponentContext, + IReadonlyTheme, +} from '@microsoft/sp-component-base'; + import { IUserInfo } from './models/IUserInfo'; export interface IUserPickerProps { @@ -10,4 +16,7 @@ export interface IUserPickerProps { onRemoveSelectedUser?: (user: IUserInfo) => void; placeholder?: string; defaultSelectdUsers?: IUserInfo[]; + theme: IReadonlyTheme | undefined; + context: BaseComponentContext; + secondaryTextPropertyName?: "jobTitle" | "department" | "mail" | "officeLocation" | "mobilePhone" | "businessPhones" | "userPrincipalName" ; } diff --git a/src/controls/userPicker/PopUpMenu.tsx b/src/controls/userPicker/PopUpMenu.tsx index ed22c0a5d..9a8483c2d 100644 --- a/src/controls/userPicker/PopUpMenu.tsx +++ b/src/controls/userPicker/PopUpMenu.tsx @@ -7,18 +7,20 @@ import { pullAllBy } from 'lodash'; import { Card } from '@fluentui/react-components'; import { User as IUser } from '@microsoft/microsoft-graph-types'; -import { globalState } from '../../atoms/globalState'; -import { useGraphUserAPI } from '../../hooks/useGraphUserAPI'; -import { useOnClickOutside } from '../../hooks/useOnClickOutside'; -import { IUserInfo } from '../../models/IUserInfo'; -import { UserCard } from '../userCard/UserCard'; -import { useSelectUserStyles } from './useSelectuserStyles'; +import { globalState } from './atoms/globalState'; +import { useGraphUserAPI } from './hooks/useGraphUserAPI'; +import { useOnClickOutside } from './hooks/useOnClickOutside'; +import { IUserInfo } from './models/IUserInfo'; +import { NoUser } from './userCard/NoUser'; +import { UserCard } from './userCard/UserCard'; +import { useUserPickerStyles } from './useUserPickerStyles'; interface IPopUpMenuProps { isOpen: boolean; searchValue: string; onDismiss: (open?: boolean) => void; target: React.RefObject; + secondaryTextPropertyName?: "jobTitle" | "department" | "mail" | "officeLocation" | "mobilePhone" | "businessPhones" | "userPrincipalName"; } export const PopUpMenu = (props: IPopUpMenuProps): JSX.Element => { @@ -27,11 +29,10 @@ export const PopUpMenu = (props: IPopUpMenuProps): JSX.Element => { const { context, selectedUsers } = appGlobalState; const [renderUsers, setRenderUsers] = React.useState([]); const { getUserByName } = useGraphUserAPI(context); - const styles = useSelectUserStyles(); + const styles = useUserPickerStyles(); useOnClickOutside(true, target, () => onDismiss(false)); - const onSelected = React.useCallback((user: IUserInfo) => { console.log(user); setAppGlobalState({ ...appGlobalState, selectedUsers: [...selectedUsers, user] }); @@ -39,6 +40,7 @@ export const PopUpMenu = (props: IPopUpMenuProps): JSX.Element => { }, []); React.useEffect(() => { + if (searchValue.length < 2) return; setTimeout(async () => { setRenderUsers([]); const users: IUser[] = (await getUserByName(searchValue)) ?? []; @@ -53,10 +55,18 @@ export const PopUpMenu = (props: IPopUpMenuProps): JSX.Element => { showOverCard={false} onSelected={onSelected} className={styles.userCardStyles} + secondaryTextPropertyName={props.secondaryTextPropertyName} /> ); } + if (usersToRender.length === 0) { + usersToRender.push( + <> + + + ); + } setRenderUsers(usersToRender); }, 500); }, [searchValue, selectedUsers, getUserByName, onSelected]); diff --git a/src/controls/userPicker/User.tsx b/src/controls/userPicker/User.tsx index 2050baf84..9e64e7c92 100644 --- a/src/controls/userPicker/User.tsx +++ b/src/controls/userPicker/User.tsx @@ -3,17 +3,18 @@ import * as React from 'react'; import { Button } from '@fluentui/react-components'; import { Dismiss16Regular } from '@fluentui/react-icons'; -import { UserCard } from '../userCard/UserCard'; -import { useSelectUserStyles } from './useSelectuserStyles'; +import { UserCard } from './userCard/UserCard'; +import { useUserPickerStyles } from './useUserPickerStyles'; export interface IUserProps { userId: string; onRemove?: (userId: string) => void; + secondaryTextPropertyName?: "jobTitle" | "department" | "mail" | "officeLocation" | "mobilePhone" | "businessPhones" | "userPrincipalName" ; } export const User: React.FunctionComponent = (props: React.PropsWithChildren) => { - const { userId, onRemove } = props; - const styles = useSelectUserStyles(); + const { userId, onRemove, secondaryTextPropertyName } = props; + const styles = useUserPickerStyles(); const onClick = React.useCallback(() => { if (onRemove) onRemove(userId); @@ -22,7 +23,7 @@ export const User: React.FunctionComponent = (props: React.PropsWith return ( <>
- + + +
+ { + onFileSelected(file); + setSelectedImageFileUrl(file.previewDataUrl); + + onDismiss(); + }} + /> + {} + + + ); +}; diff --git a/src/controls/imagePicker/RenderSpninner/RenderSpinner.tsx b/src/controls/imagePicker/RenderSpninner/RenderSpinner.tsx new file mode 100644 index 000000000..2751f3117 --- /dev/null +++ b/src/controls/imagePicker/RenderSpninner/RenderSpinner.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import { + makeStyles, + mergeClasses, + Spinner, +} from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: "flex", + justifyContent: "center", + alignItems: "center", + + height: "100%", + width: "100%", + }, + spinner: { + width: "100px", + height: "100px", + }, + }); + +export interface IRenderSpinnerProps { + size: "medium" | "small" | "extra-tiny" | "tiny" | "extra-small" | "large" | "extra-large" | "huge"; + label?: string; + labelPosition?: "above" | "below" | "before" | "after"; + style?: React.CSSProperties; + className?: string; +} + +export const RenderSpinner: React.FunctionComponent = ( + props: React.PropsWithChildren +) => { + const { size, label, labelPosition, style, className } = props; + + const styles = useStyles(); + return ( +
+ +
+ ); +}; diff --git a/src/controls/imagePicker/SelectStokImage.tsx b/src/controls/imagePicker/SelectStokImage.tsx new file mode 100644 index 000000000..88259975d --- /dev/null +++ b/src/controls/imagePicker/SelectStokImage.tsx @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import * as React from "react"; + +import { ApplicationCustomizerContext } from "@microsoft/sp-application-base"; +import { BaseComponentContext } from "@microsoft/sp-component-base"; + +import { + CONTENT_IMAGE_STOCK_URL, + CONTENT_URL, +} from "./constants/constants"; +import { useSpAPI } from "./hooks/useSpAPI"; +import { useUtils } from "./hooks/useUtils"; +import { IFilePickerResult } from "./IFilePickerResult"; +import { + StockImagesEvent, + SubmitValue, +} from "./StockImagesModel"; +import { useImagePickerStyles } from "./useImagePickerStyles"; + +export interface ISelectStockImageProps { + onFileSelected: (file: any) => void; + onCancel: () => void; + context: ApplicationCustomizerContext | BaseComponentContext | undefined; +} + +/** + * Renders a component that allows the user to select a stock image. + * + * @component + * @example + * ```tsx + * + * ``` + */ + +export const SelectStockImage: React.FunctionComponent = ( + props: React.PropsWithChildren +) => { + const { context, onFileSelected, onCancel } = props; + + const { getFileNameFromUrl, getFileNameWithoutExtension } = useUtils(); + const { downloadBingContent } = useSpAPI(context); + const styles = useImagePickerStyles(); + + const handleSave = (event: StockImagesEvent): void => { + let filePickerResult: IFilePickerResult = null; + const cdnFileInfo: SubmitValue = + event.Values && (event.Values as SubmitValue[]).length > 0 ? (event.Values as SubmitValue[])[0] : null; + if (cdnFileInfo) { + filePickerResult = { + downloadFileContent: () => { + return downloadBingContent( + cdnFileInfo.sourceUrl, + getFileNameFromUrl(getFileNameFromUrl(cdnFileInfo.sourceUrl)) + ); + }, + fileAbsoluteUrl: cdnFileInfo.sourceUrl, + fileName: getFileNameFromUrl(cdnFileInfo.sourceUrl), + fileNameWithoutExtension: getFileNameWithoutExtension(cdnFileInfo.sourceUrl), + previewDataUrl: cdnFileInfo.sourceUrl, + }; + } + onFileSelected(filePickerResult); + }; + + const handleImageIframeEvent = (event: MessageEvent) => { + if (!event || !event.origin || event.origin.indexOf(CONTENT_URL) !== 0) { + return; + } + + const eventData: StockImagesEvent = JSON.parse(event.data); + + if (eventData.MessageId === "AddItem") { + handleSave(eventData); + } else if (eventData.MessageId === "CancelDialog") { + onCancel(); + } + }; + + React.useLayoutEffect(() => { + window.addEventListener("message", handleImageIframeEvent); + return () => { + window.removeEventListener("message", handleImageIframeEvent); + }; + }, []); + + return ( + <> +
+