From febe2342eb94d46c8d718983628ad6e80af96079 Mon Sep 17 00:00:00 2001
From: Alex Terentiev <aleksei.terentiev@gmail.com>
Date: Mon, 5 Apr 2021 15:57:10 -0700
Subject: [PATCH] basic UI

---
 src/controls/sitePicker/ISitePicker.ts        |  78 ++++++++++
 src/controls/sitePicker/SitePicker.tsx        | 138 ++++++++++++++++++
 src/services/SPSitesService.ts                |  93 ++++++++++++
 .../controlsTest/components/ControlsTest.tsx  |  10 ++
 4 files changed, 319 insertions(+)
 create mode 100644 src/controls/sitePicker/ISitePicker.ts
 create mode 100644 src/controls/sitePicker/SitePicker.tsx
 create mode 100644 src/services/SPSitesService.ts

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<ISitePickerProps> = (props: React.PropsWithChildren<ISitePickerProps>) => {
+
+  const {
+    label,
+    disabled,
+    context,
+    initialSites,
+    multiSelect,
+    mode,
+    limitToCurrentSiteCollection,
+    allowSearch,
+    orderBy: propOrderBy,
+    isDesc,
+    onChange
+  } = props;
+
+  const [isLoading, setIsLoading] = React.useState<boolean>(true);
+  const [selectedSites, setSelectedSites] = React.useState<ISite[]>();
+  const [allSites, setAllSites] = React.useState<ISite[]>();
+  const [filteredSites, setFilteredSites] = React.useState<ISite[]>();
+  const [searchQuery, setSearchQuery] = React.useState<string>();
+
+  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<ISite[]>;
+    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 <div className={styles.loadingSpinnerContainer}>
+      <Spinner size={SpinnerSize.medium} />
+    </div>;
+  }
+
+  return (
+    <>
+      <Dropdown
+        label={label}
+        options={getOptions()}
+        disabled={disabled}
+        multiSelect={multiSelect !== false}
+      />
+    </>
+  );
+};
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<ISite[]> => {
+  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<ISite[]> => {
+
+  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<ISite[]> => {
+  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<IControlsTestProps, IC
                 <FileTypeIcon type={IconType.image} size={this.state.imgSize} />
               </div>
 
+              <div className="ms-font-m">Site picker tester:
+                <SitePicker
+                  context={this.props.context}
+                  label={'select sites'}
+                  mode={'web'}
+                  allowSearch={true}
+                  onChange={() => {}} />
+              </div>
+
               <div className="ms-font-m">List picker tester:
                 <ListPicker context={this.props.context}
                   label="Select your list(s)"