diff --git a/src/controls/sitePicker/ISitePicker.ts b/src/controls/sitePicker/ISitePicker.ts new file mode 100644 index 000000000..a0833f7b3 --- /dev/null +++ b/src/controls/sitePicker/ISitePicker.ts @@ -0,0 +1,78 @@ +import { BaseComponentContext } from '@microsoft/sp-component-base'; + +export interface ISite { + /** + * ID of the site + */ + id?: string; + /** + * Title + */ + title?: string; + /** + * Base URL + */ + url?: string; + + /** + * ID of the web + */ + webId?: string; + + /** + * ID of the hub site + */ + hubSiteId?: string; +} + +export interface ISitePickerProps { + /** + * Site picker label + */ + label?: string; + /** + * Specify if the control needs to be disabled + */ + disabled?: boolean; + /** + * Web Part context + */ + context: BaseComponentContext; + /** + * Intial data to load in the 'Selected sites' area (optional) + */ + initialSites?: ISite[]; + /** + * Define if you want to allow multi site selection. True by default. + */ + multiSelect?: boolean; + /** + * Defines what entities are available for selection: site collections, sites, hub sites. + */ + mode?: 'site' | 'web' | 'hub'; + + /** + * Specifies if the options should be limited by the current site collections. Taken into consideration if selectionMode is set to 'web' + */ + limitToCurrentSiteCollection?: boolean; + + /** + * Specifies if search box is displayed for the component. Default: true + */ + allowSearch?: boolean; + + /** + * Specifices if the list is sorted by title or url. Default: title + */ + orderBy?: 'title' | 'url'; + + /** + * Specifies if the list is sorted in descending order. Default: false + */ + isDesc?: boolean; + + /** + * selection change handler + */ + onChange: (selectedSites: ISite[]) => void; +} diff --git a/src/controls/sitePicker/SitePicker.tsx b/src/controls/sitePicker/SitePicker.tsx new file mode 100644 index 000000000..e0ec173f0 --- /dev/null +++ b/src/controls/sitePicker/SitePicker.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { mergeStyleSets } from 'office-ui-fabric-react/lib/Styling'; +import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; +import { ISite, ISitePickerProps } from './ISitePicker'; +import { getAllSites, getHubSites } from '../../services/SPSitesService'; +import { IDropdownOption, Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; +import { SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/utilities/selectableOption/SelectableOption.types'; +import orderBy from 'lodash/orderBy'; + +const styles = mergeStyleSets({ + loadingSpinnerContainer: { + width: '100%', + textAlign: 'center' + } +}); + +export const SitePicker: React.FunctionComponent = (props: React.PropsWithChildren) => { + + const { + label, + disabled, + context, + initialSites, + multiSelect, + mode, + limitToCurrentSiteCollection, + allowSearch, + orderBy: propOrderBy, + isDesc, + onChange + } = props; + + const [isLoading, setIsLoading] = React.useState(true); + const [selectedSites, setSelectedSites] = React.useState(); + const [allSites, setAllSites] = React.useState(); + const [filteredSites, setFilteredSites] = React.useState(); + const [searchQuery, setSearchQuery] = React.useState(); + + const getOptions = (): IDropdownOption[] => { + const result: IDropdownOption[] = []; + + if (allowSearch) { + result.push({ + key: 'search', + text: '', + itemType: SelectableOptionMenuItemType.Header + }); + } + + const selectedSitesIds: string[] = selectedSites ? selectedSites.map(s => s.id!) : []; + + if (filteredSites) { + filteredSites.forEach(s => { + result.push({ + key: s.id, + text: s.title, + data: s, + selected: selectedSitesIds.indexOf(s.id) !== -1 + }); + }); + } + + return result; + }; + + React.useEffect(() => { + if (!initialSites) { + return; + } + + setSelectedSites(sites => { + if (!sites) { // we want to set the state one time only + return initialSites; + } + + return sites; + }); + }, [initialSites]); + + React.useEffect(() => { + if (!context) { + return; + } + + setIsLoading(true); + setSearchQuery(''); + setFilteredSites([]); + + let promise: Promise; + if (mode === 'hub') { + promise = getHubSites(context); + } + else { + promise = getAllSites(context, mode === 'web', limitToCurrentSiteCollection); + } + + promise.then(sites => { + const copy = orderBy(sites, [propOrderBy || 'title'], [isDesc ? 'desc' : 'asc']); + setAllSites(copy); + setIsLoading(false); + }); + }, [context, mode, limitToCurrentSiteCollection]); + + React.useEffect(() => { + setAllSites(sites => { + if (!sites) { + return sites; + } + + const copy = orderBy(sites, [propOrderBy || 'title'], [isDesc ? 'desc' : 'asc']); + return copy; + }); + }, [propOrderBy, isDesc]); + + React.useEffect(() => { + if (!allSites) { + return; + } + setFilteredSites([...allSites]); + }, [allSites]); + + if (isLoading) { + return
+ +
; + } + + return ( + <> + + + ); +}; diff --git a/src/services/SPSitesService.ts b/src/services/SPSitesService.ts new file mode 100644 index 000000000..d88860ee5 --- /dev/null +++ b/src/services/SPSitesService.ts @@ -0,0 +1,93 @@ +import { BaseComponentContext } from '@microsoft/sp-component-base'; +import { ISite } from '../controls/sitePicker/ISitePicker'; +import { SPHttpClient } from '@microsoft/sp-http'; + +const getAllSitesInternal = async (ctx: BaseComponentContext, queryText: string): Promise => { + let startRow = 0; + let rowLimit = 500; + let totalRows = 0; + const values: any[] = []; + + // + // getting all sites + // + do { + let userRequestUrl: string = `${ctx.pageContext.web.absoluteUrl}/_api/search/query?querytext='${queryText}'&selectproperties='SiteId,SiteID,WebId,DepartmentId,Title,Path'&rowlimit=${rowLimit}&startrow=${startRow}`; + let searchResponse = await ctx.spHttpClient.get(userRequestUrl, SPHttpClient.configurations.v1); + let sitesResponse = await searchResponse.json(); + let relevantResults = sitesResponse.PrimaryQueryResult.RelevantResults; + + values.push(...relevantResults.Table.Rows); + totalRows = relevantResults.TotalRows; + startRow += rowLimit; + + } while (values.length < totalRows); + + // Do the call against the SP REST API search endpoint + + let res: ISite[] = []; + res = values.map(element => { + const site: ISite = {} as ISite; + element.Cells.forEach(cell => { + switch (cell.Key) { + case 'Title': + site.title = cell.Value; + break; + case 'Path': + site.url = cell.Value; + break; + case 'SiteId': + case 'SiteID': + site.id = cell.Value; + break; + case 'WebId': + site.webId = cell.Value; + break; + case 'DepartmentId': + if (cell.Value) { + if (cell.Value.indexOf('{') === 0) { + site.hubSiteId = cell.Value.slice(1, -1); + } + else { + site.hubSiteId = cell.Value; + } + } + break; + } + }); + + return site; + }); + return res; +}; + +export const getAllSites = async (ctx: BaseComponentContext, includeWebs: boolean, currentSiteCollectionOnly: boolean): Promise => { + + let rootUrl: string = ctx.pageContext.web.absoluteUrl; + if (ctx.pageContext.web.serverRelativeUrl !== '/' && (!includeWebs || !currentSiteCollectionOnly)) { + rootUrl = ctx.pageContext.web.absoluteUrl.replace(ctx.pageContext.web.serverRelativeUrl, ''); + } + + const queryText = `contentclass:STS_Site${includeWebs ? ' contentclass:STS_Web' : ''} Path:${rootUrl}*`; + + return getAllSitesInternal(ctx, queryText); +}; + +export const getHubSites = async (ctx: BaseComponentContext): Promise => { + const hubSites: ISite[] = []; + + const requestUrl = `${ctx.pageContext.site.absoluteUrl}/_api/HubSites?$select=SiteId,ID,SiteUrl,Title`; + const response = await ctx.spHttpClient.get(requestUrl, SPHttpClient.configurations.v1); + const json = await response.json(); + + json.value.forEach(v => { + hubSites.push({ + title: v.Title, + id: v.SiteId, + hubSiteId: v.ID, + url: v.SiteUrl + }); + }); + + return hubSites; +}; diff --git a/src/webparts/controlsTest/components/ControlsTest.tsx b/src/webparts/controlsTest/components/ControlsTest.tsx index a77b54e66..eafa478ae 100644 --- a/src/webparts/controlsTest/components/ControlsTest.tsx +++ b/src/webparts/controlsTest/components/ControlsTest.tsx @@ -163,6 +163,7 @@ import { IControlsTestState } from "./IControlsTestProps"; import { DragDropFiles } from "../../../DragDropFiles"; +import { SitePicker } from "../../../controls/sitePicker/SitePicker"; // Used to render document card /** @@ -1310,6 +1311,15 @@ export default class ControlsTest extends React.Component +
Site picker tester: + {}} /> +
+
List picker tester: