Merge pull request #1838 from albegut/master

This commit is contained in:
Hugo Bernier 2021-05-01 22:14:14 -04:00 committed by GitHub
commit 40e55bc5cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1689 additions and 843 deletions

View File

@ -3,7 +3,8 @@
## Summary
This list search web part allows the user to show data from lists or libraries. The web part can be used to (for more details see images below):
* [Show merged items from diferents lists/libraries](#merge-items-from-different-listslibraries)
* [Show merged items from different lists/libraries](#merge-items-from-different-listslibraries)
* [Open item data in modal window (same data shown in the table)](#merge-items-from-different-listslibraries)
* [Select render by field type](#select-render-of-the-selected-fields)
* [Open item detail in modal window (it allows to select the fields to show by list)](#open-selected-item-with-selected-properties)
@ -13,6 +14,7 @@ This list search web part allows the user to show data from lists or libraries.
* [Redirect to url](#redirect-to-url-depends-on-selected-item)
* Other useful functionalities:
* List item modern audience support
* General filter - the user can select which columns are filtered and which not
* Column filter on each column
* Item limit to show
@ -22,39 +24,38 @@ This list search web part allows the user to show data from lists or libraries.
* Get section color
* Show item count with custom message
#### Merge items from different lists/libraries
### Merge items from different lists/libraries
![Merge items from different lists/libraries](assets/differentSources.gif)
#### Select render of the selected fields
### Select render of the selected fields
![Select render of the selected fields](assets/selectFieldRenderType.gif)
#### Open documents in modal window
### Open documents in modal window
![Open documents in modal window](assets/docInModal.gif)
#### Open documents in new tab
### Open documents in new tab
![Open documents in new tab](assets/docInNewTab.gif)
#### Use of dynamic data
### Use of dynamic data
![Use of dynamic data](assets/dynamicData.gif)
#### Open selected item with same data
### Open selected item with same data
![Open selected item with same data](assets/itemCurrentData.gif)
#### Open selected item with selected properties
### Open selected item with selected properties
![Open selected item with selected properties](assets/itemSelectedData.gif)
#### Redirect to url depends on selected item
### Redirect to url depends on selected item
![Redirect to url depends on selected item](assets/redirectToUrl.gif)
## Compatibility
![SPFx 1.11](https://img.shields.io/badge/SPFx-1.11.0-green.svg)
@ -63,7 +64,6 @@ This list search web part allows the user to show data from lists or libraries.
![Teams N/A: Untested with Microsoft Teams](https://img.shields.io/badge/Teams-N%2FA-lightgrey.svg "Untested with Microsoft Teams")
![Workbench Local | Hosted](https://img.shields.io/badge/Workbench-Local%20%7C%20Hosted-green.svg)
## Applies to
* [SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
@ -73,19 +73,14 @@ This list search web part allows the user to show data from lists or libraries.
Solution|Author(s)
--------|---------
react-list-search | Alberto Gutiérrez ([@albertogperez](https://twitter.com/albertogperez))
react-list-search | [Alberto Gutiérrez](https://github.com/albegut) ([@albertogperez](https://twitter.com/albertogperez))
## Version history
Version|Date|Comments
-------|----|--------
1.0|December 20, 2020|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
1.0.0|December 20, 2020|Initial release
1.1.0|April 25, 2021|List item modern audience support
## Minimal Path to Awesome
@ -103,6 +98,7 @@ Version|Date|Comments
* Download `.sppkg` files from `sppkg` folder
* Upload files to **App Catalog**
* Approve the API permissions in the new SP admin center (only needed if you are going to enable list item modern audience)
## Features
@ -112,8 +108,22 @@ This Web Part illustrates the following concepts on top of the SharePoint Framew
* Using [PnP Js](https://pnp.github.io/pnpjs) to retrieve SharePoint data
* Using [PnP Js](https://pnp.github.io/pnpjs/odata/caching) to cache SharePoint data
* Connection between SharePoint Framework components using dynamic data
* [Support of section backgrounds color ](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/supporting-section-backgrounds)
* [Support of section backgrounds color](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/supporting-section-backgrounds)
* [Custom property pane control](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/build-custom-property-pane-controls)
* Use [react-js-pagination](https://www.npmjs.com/package/react-js-pagination) library
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
## Support
We do not support samples, but we do use GitHub to track issues and constantly want to improve these samples.
If you encounter any issues while using this sample, [create a new issue](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=bug-report.yml&sample=react-list-search&authors=@albegut&title=react-list-search%20-%20).
For questions regarding this sample, [create a new question](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=question.yml&sample=react-list-search&authors=@albegut&title=react-list-search%20-%20).
Finally, if you have an idea for improvement, [make a suggestion](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected&template=suggestion.yml&sample=react-list-search&authors=@albegut&title=react-list-search%20-%20).
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-list-search" />

View File

@ -8,8 +8,8 @@
"longDescription": [
"This list search web part allows the user to show data from lists or libraries."
],
"created": "2020-12-20",
"modified": "2020-12-20",
"creationDateTime": "2020-12-20",
"updateDateTime": "2021-04-25",
"products": [
"SharePoint",
"Office"
@ -100,4 +100,4 @@
}
]
}
]
]

View File

@ -3,7 +3,7 @@
"solution": {
"name": "list-search-webpart",
"id": "8277f088-9c30-4f95-9c15-9c18a9d40a26",
"version": "1.0.0.0",
"version": "1.1.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
@ -13,7 +13,25 @@
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": ""
}
},
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "User.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "User.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.Read.All"
},
{
"resource": "Microsoft Graph",
"scope": "Directory.ReadWrite.All"
}
]
},
"paths": {
"zippedPackage": "solution/list-search-webpart.sppkg"

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,15 @@
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-property-pane": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/graph": "2.4.0",
"@pnp/sp": "2.0.8",
"@pnp/spfx-controls-react": "1.19.0",
"@pnp/spfx-property-controls": "1.19.0",
"react-js-pagination": "3.0.3",
"react-xml-parser": "1.1.6",
"@pnp/spfx-controls-react": "2.4.0",
"@pnp/spfx-property-controls": "2.5.0",
"office-ui-fabric-react": "7.155.3",
"react": "16.8.5",
"react-dom": "16.8.5",
"office-ui-fabric-react": "7.155.3"
"react-js-pagination": "3.0.3",
"react-xml-parser": "1.1.6"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.11.0",

View File

@ -31,6 +31,7 @@ import { IDynamicItem } from './model/IDynamicItem';
import { PropertyPaneWebPartInformation } from '@pnp/spfx-property-controls/lib/PropertyPaneWebPartInformation';
import { SharePointFieldTypes, SharePointType } from './model/ISharePointFieldTypes';
import { IModalType } from './model/IModalType';
import { find, has } from '@microsoft/sp-lodash-subset';
@ -76,6 +77,7 @@ export interface IListSearchWebPartProps {
initialQueryOption: "simpleText" | "dynamicData";
dynamicQueryText: DynamicProperty<string>;
initialQueryText: string;
title: string;
}
export default class ListSearchWebPart extends BaseClientSideWebPart<IListSearchWebPartProps> implements IDynamicDataCallables {
@ -294,7 +296,12 @@ export default class ListSearchWebPart extends BaseClientSideWebPart<IListSearch
AnyCamlQuery: (this.properties.listsCollectionData.findIndex(listConfig => !this.isEmpty(listConfig.Query) || !this.isEmpty(listConfig.ListView)) > -1),
groupByFieldType: this.properties.groupByFieldType,
CacheType: this.properties.CacheType,
generalFilterText: queryText
generalFilterText: queryText,
title: this.properties.title,
updateTitle: (value: string) => {
this.properties.title = value;
},
displayMode: this.displayMode,
}
);
renderElement = element;
@ -574,7 +581,7 @@ export default class ListSearchWebPart extends BaseClientSideWebPart<IListSearch
]
}) : emptyProperty;
let GeneralFilterInitialQueryText = this.properties.initialQueryEnabled && this.properties.initialQueryOption === "simpleText" ? PropertyPaneTextField('initialQueryText', {
let GeneralFilterInitialQueryText = this.properties.initialQueryEnabled && this.properties.initialQueryOption === "simpleText" ? PropertyPaneTextField('initialQueryText', {
label: strings.GeneralFilterInitialQueryTextValue,
}) : emptyProperty;
@ -866,6 +873,11 @@ export default class ListSearchWebPart extends BaseClientSideWebPart<IListSearch
title: strings.CollectionDataListCamlQueryTitle,
placeholder: strings.CollectionDataListCamlQueryPlaceHolder,
type: CustomCollectionFieldType.string,
},
{
id: "AudienceEnabled",
title: strings.ModerAudienceEnabledTitle,
type: CustomCollectionFieldType.boolean,
}
],
disabled: !this.properties.sites || this.properties.sites.length == 0,

View File

@ -5,6 +5,7 @@ import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { SharePointType } from "../model/ISharePointFieldTypes";
import { IModalType } from "../model/IModalType";
import { IDynamicItem } from "../model/IDynamicItem";
import { DisplayMode } from "@microsoft/sp-core-library";
export interface IListSearchProps {
@ -47,4 +48,7 @@ export interface IListSearchProps {
AnyCamlQuery: boolean;
CacheType: "session" | "local";
generalFilterText: string;
title: string;
updateTitle: (title: string) => void;
displayMode: DisplayMode;
}

View File

@ -4,21 +4,22 @@ import * as strings from 'ListSearchWebPartStrings';
import ListService from '../services/ListService';
import IGroupedItems, { IListSearchState, IColumnFilter } from './IListSearchState';
import { IListSearchProps } from './IListSearchProps';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import {
DetailsList,
IColumn,
IDetailsFooterProps,
IDetailsRowBaseProps,
DetailsRow,
SelectionMode,
IGroup
IGroup,
DetailsHeader,
DetailsListLayoutMode,
} from 'office-ui-fabric-react/lib/DetailsList';
import {
getTheme,
IconButton,
MessageBar,
MessageBarType
MessageBarType,
ShimmeredDetailsList
} from 'office-ui-fabric-react';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import Pagination from "react-js-pagination";
@ -41,9 +42,11 @@ import IUserField from '../model/IUserField';
import IUrlField from '../model/IUrlField';
import { IFrameDialog } from "@pnp/spfx-controls-react/lib/IFrameDialog";
import { IModalType } from '../model/IModalType';
import { groupBy, isEmpty } from '@microsoft/sp-lodash-subset';
import { find, groupBy, isEmpty } from '@microsoft/sp-lodash-subset';
import IResult from '../model/IResult';
import { IListSearchListQuery, IMapQuery } from '../model/IMapQuery';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import GraphService from '../services/GraphService';
const LOG_SOURCE = "IListdSearchWebPart";
@ -52,6 +55,8 @@ const filterIcon: IIconProps = { iconName: 'Filter' };
export default class IListdSearchWebPart extends React.Component<IListSearchProps, IListSearchState> {
private groups: IGroup[];
private keymapQuerys: IMapQuery = {};
private _graphService: GraphService;
constructor(props: IListSearchProps, state: IListSearchState) {
super(props);
this.state = {
@ -67,11 +72,12 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
isModalLoading: false,
selectedItem: null,
completeModalItemData: null,
columns: [],
columns: this.AddColumnsToDisplay(),
groupedItems: []
};
this.GetJSXElementByType = this.GetJSXElementByType.bind(this);
this._renderItemColumn = this._renderItemColumn.bind(this);
this._graphService = new GraphService(this.props.Context);
}
@ -109,7 +115,6 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
result = result.slice(0, this.props.ItemLimit);
}
let columns = this.AddColumnsToDisplay();
let groupedItems = [];
if (this.props.groupByField) {
groupedItems = this._groupBy(result, this.props.groupByField, this.props.groupByFieldType);
@ -120,7 +125,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
filteredItems = this.filterListItemsByGeneralFilter(this.props.generalFilterText, false, false, result, filteredItems);
}
this.setState({ items: result, filterItems: filteredItems, isLoading: false, columns, groupedItems });
this.setState({ items: result, filterItems: filteredItems, isLoading: false, groupedItems });
} catch (error) {
this.SetError(error, "getData");
}
@ -134,7 +139,13 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
let listService: ListService = new ListService(site, this.props.UseCache, this.props.minutesToCache, this.props.CacheType);
let siteProperties = this.props.Sites.filter(siteInformation => siteInformation.url === site);
Object.keys(this.keymapQuerys[site]).map(listQuery => {
itemPromise.push(listService.getListItems(this.keymapQuerys[site][listQuery], this.props.ListNameTitle, this.props.SiteNameTitle, siteProperties[0][this.props.SiteNamePropertyToShow], this.props.ItemLimit));
itemPromise.push(listService.getListItems(
this.keymapQuerys[site][listQuery],
this.props.ListNameTitle,
this.props.SiteNameTitle,
siteProperties[0][this.props.SiteNamePropertyToShow],
this.props.ItemLimit,
this._graphService));
});
});
@ -189,16 +200,26 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
}
}
else {
let listQueryInfo = this.props.listsCollectionData.filter(list => list.SiteCollectionSource == item.SiteCollectionSource && list.ListSourceField == item.ListSourceField);
let newQueryListItem: IListSearchListQuery = { list: { Id: item.ListSourceField, Title: item.ListSourceFieldName }, fields: [{ originalField: item.SourceField, newField: item.TargetField, fieldType: item.SPFieldType }], camlQuery: listQueryInfo.length > 0 && listQueryInfo[0].Query, viewName: listQueryInfo.length > 0 && listQueryInfo[0].ListView };
let listQueryInfo = find(this.props.listsCollectionData, list => list.SiteCollectionSource == item.SiteCollectionSource && list.ListSourceField == item.ListSourceField);
let newQueryListItem: IListSearchListQuery = {
list: { Id: item.ListSourceField, Title: item.ListSourceFieldName },
audienceEnabled: listQueryInfo.AudienceEnabled,
fields: [{ originalField: item.SourceField, newField: item.TargetField, fieldType: item.SPFieldType }],
camlQuery: listQueryInfo && listQueryInfo.Query,
viewName: listQueryInfo && listQueryInfo.ListView
};
this.keymapQuerys[item.SiteCollectionSource][item.ListSourceField] = newQueryListItem;
}
}
else {
let listQueryInfo = this.props.listsCollectionData.filter(list => list.SiteCollectionSource == item.SiteCollectionSource && list.ListSourceField == item.ListSourceField);
let newQueryListItem: IListSearchListQuery = { list: { Id: item.ListSourceField, Title: item.ListSourceFieldName }, fields: [{ originalField: item.SourceField, newField: item.TargetField, fieldType: item.SPFieldType }], camlQuery: listQueryInfo.length > 0 && listQueryInfo[0].Query, viewName: listQueryInfo.length > 0 && listQueryInfo[0].ListView };
let listQueryInfo = find(this.props.listsCollectionData, list => list.SiteCollectionSource == item.SiteCollectionSource && list.ListSourceField == item.ListSourceField);
let newQueryListItem: IListSearchListQuery = {
list: { Id: item.ListSourceField, Title: item.ListSourceFieldName },
audienceEnabled: listQueryInfo.AudienceEnabled,
fields: [{ originalField: item.SourceField, newField: item.TargetField, fieldType: item.SPFieldType }],
camlQuery: listQueryInfo && listQueryInfo.Query,
viewName: listQueryInfo && listQueryInfo.ListView
};
this.keymapQuerys[item.SiteCollectionSource] = [];
this.keymapQuerys[item.SiteCollectionSource][item.ListSourceField] = newQueryListItem;
}
@ -300,27 +321,38 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
}
private _onRenderDetails(detailsFooterProps: IDetailsFooterProps): JSX.Element {
let _renderDetailsFooterItemColumn: IDetailsRowBaseProps['onRenderItemColumn'] = (item, index, column) => {
let filter: IColumnFilter = this.state.columnFilters.find(colFilter => colFilter.columnName == column.name);
if (this.props.IndividualColumnFilter && column.data != SharePointType.FileIcon) {
return (
<SearchBox placeholder={column.name} iconProps={filterIcon} value={filter ? filter.filterToApply : ""}
underlined={true} onChange={(ev, value) => this.filterColumnListItems(column.name, value, column.data)} onClear={(ev) => this.filterColumnListItems(column.name, "", SharePointType.Text)} />
);
private _onRenderDetails(detailsFooterProps: IDetailsFooterProps, showSearchBox: boolean, isHeader: boolean): JSX.Element {
if (this.props.IndividualColumnFilter) {
let _renderDetailsFooterItemColumn: IDetailsRowBaseProps['onRenderItemColumn'] = (item, index, column) => {
let filter: IColumnFilter = this.state.columnFilters.find(colFilter => colFilter.columnName == column.name);
if (this.props.IndividualColumnFilter && showSearchBox && column.data != SharePointType.FileIcon) {
return (
<SearchBox placeholder={column.name} iconProps={filterIcon} value={filter ? filter.filterToApply : ""}
underlined={true} onChange={(ev, value) => this.filterColumnListItems(column.name, value, column.data)} onClear={(ev) => this.filterColumnListItems(column.name, "", SharePointType.Text)} />
);
}
else {
return undefined;
}
};
return (
<DetailsRow
{...detailsFooterProps}
item={{}}
itemIndex={-1}
onRenderItemColumn={_renderDetailsFooterItemColumn}
/>
);
}
else {
if (isHeader) {
return <DetailsHeader {...detailsFooterProps} layoutMode={DetailsListLayoutMode.justified} styles={{ root: { backgroundColor: 'transparent' } }} />;
}
else {
return undefined;
}
};
return (
<DetailsRow
{...detailsFooterProps}
item={{}}
itemIndex={-1}
onRenderItemColumn={_renderDetailsFooterItemColumn}
/>
);
}
}
private handlePageChange(pageNumber) {
@ -371,6 +403,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
let completeItemQueryOptions: IListSearchListQuery = {
list: item.List,
audienceEnabled: true,
fields: this.props.completeModalFields && this.props.completeModalFields.filter(field => field.SiteCollectionSource == item.SiteUrl &&
field.ListSourceField == item.List.Id).map(field => { return { originalField: field.SourceField, newField: field.TargetField, fieldType: field.SPFieldType }; })
};
@ -540,21 +573,20 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
return this.props.clickEnabled ?
this.props.oneClickOption ?
<div onClick={() => this._onItemInvoked(detailrow.item)}>
{defaultRender({ ...detailrow, styles: { root: { cursor: 'pointer' } } })}
{defaultRender({ ...detailrow, styles: { root: { cursor: 'pointer', backgroundColor: 'transparent' } } })}
</div>
:
<>
{defaultRender({ ...detailrow, styles: { root: { cursor: 'pointer' } } })}
{defaultRender({ ...detailrow, styles: { root: { cursor: 'pointer', backgroundColor: 'transparent' } } })}
</>
:
<>
{defaultRender({ ...detailrow })}
{defaultRender({ ...detailrow, styles: { root: { backgroundColor: 'transparent' } } })}
</>;
}
private _renderItemColumn(item: any, index: number, column: IColumn): JSX.Element {
let result = this.GetJSXElementByType(item, column.fieldName, column.data);
return result;
return this.GetJSXElementByType(item, column.fieldName, column.data);
}
private GetModalBodyRenderByFieldType(item: any, propertyName: string, fieldType: SharePointType): JSX.Element {
@ -649,7 +681,8 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
private GetJSXElementByType(item: any, fieldName: string, fieldType: SharePointType, ommitCamlQuery: boolean = false): JSX.Element {
const value: any = this.GetItemValueFieldByFieldType(item, fieldName, fieldType, ommitCamlQuery);
let result;
const { semanticColors }: IReadonlyTheme = this.props.themeVariant;
let result: JSX.Element = <span></span>;
switch (fieldType) {
case SharePointType.FileIcon:
{
@ -659,7 +692,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
case SharePointType.User:
{
if (this.props.AnyCamlQuery && !ommitCamlQuery) {
result = <span>{value}</span>;
result = <span style={{ color: semanticColors.bodyText }}>{value}</span>;
}
else {
if (value && value.Name) {
@ -684,10 +717,10 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
if (this.props.AnyCamlQuery && !ommitCamlQuery && value && value.length > 0) {
result = <span>{value.map((val, index) => {
if (index + 1 == value.length) {
return <span>{val}</span>;
return <span style={{ color: semanticColors.bodyText }}>{val}</span>;
}
else {
return <span>{val}<br></br></span>;
return <span style={{ color: semanticColors.bodyText }}>{val}<br></br></span>;
}
})}
</span>;
@ -703,6 +736,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
maxDisplayablePersonas={3}
overflowButtonType={OverflowButtonType.descriptive}
overflowButtonProps={overflowButtonProps}
/>;
}
else {
@ -716,10 +750,10 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
if (value) {
result = <span>{value.map((val, index) => {
if (index + 1 == value.length) {
return <span>{val}</span>;
return <span style={{ color: semanticColors.bodyText }}>{val}</span>;
}
else {
return <span>{val}<br></br></span>;
return <span style={{ color: semanticColors.bodyText }}>{val}<br></br></span>;
}
})}
</span>;
@ -731,7 +765,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
}
case SharePointType.Lookup:
if (value) {
result = <Link href="#">{value}</Link>;
result = <Link style={{ color: semanticColors.bodyText }} href="#">{value}</Link>;
}
else {
result = <span></span>;
@ -741,10 +775,10 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
if (value) {
result = <span>{value.map((val, index) => {
if (index + 1 == value.length) {
return <span>{val}</span>;
return <span style={{ color: semanticColors.bodyText }}>{val}</span>;
}
else {
return <span>{val}<br></br></span>;
return <span style={{ color: semanticColors.bodyText }}>{val}<br></br></span>;
}
})}
</span>;
@ -757,10 +791,10 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
if (value) {
result = <span>{value.map((val, index) => {
if (index + 1 == value.length) {
return <Link href="#">{val}</Link>;
return <Link style={{ color: semanticColors.bodyText }} href="#">{val}</Link>;
}
else {
return <span><Link href="#">{val}</Link><br></br></span>;
return <span><Link style={{ color: semanticColors.bodyText }} href="#">{val}</Link><br></br></span>;
}
})}
</span>;
@ -771,7 +805,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
break;
case SharePointType.Url:
if (value && value.Url) {
result = <Link href={value.Url}>{value.Description}</Link>;
result = <Link href={value.Url} style={{ color: semanticColors.bodyText }}>{value.Description}</Link>;
}
else {
result = <span></span>;
@ -809,7 +843,7 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
break;
}
default:
result = <span>{value}</span>;
result = <span style={{ color: semanticColors.bodyText }}>{value}</span>;
break;
}
@ -1025,75 +1059,82 @@ export default class IListdSearchWebPart extends React.Component<IListSearchProp
let clearAllButton = this.props.ClearAllFiltersBtnColor == "white" ? <DefaultButton text={this.props.ClearAllFiltersBtnText} className={styles.btn} onClick={(ev) => this._clearAllFilters()} /> :
<PrimaryButton text={this.props.ClearAllFiltersBtnText} className={styles.btn} onClick={(ev) => this._clearAllFilters()} />;
return (
<div className={styles.listSearch} style={{ backgroundColor: semanticColors.bodyBackground }}>
<div className={styles.listSearch} style={{ backgroundColor: semanticColors.bodyBackground, color: semanticColors.bodyText }}>
<div className={styles.row}>
<div className={styles.column}>
{this.state.isLoading ?
<Spinner label={strings.ListSearchLoading} size={SpinnerSize.large} style={{ backgroundColor: semanticColors.bodyBackground }} /> :
this.state.errorMsg ?
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={false}
truncated={true}>
<b>{this.state.errorHeader}</b>{this.state.errorMsg}
</MessageBar> :
<React.Fragment>
{this.props.clickEnabled && !this.state.isModalHidden && this.state.selectedItem && this.GetOnClickAction()}
<div className={styles.rowTopInformation}>
{this.props.GeneralFilter &&
<div className={this.props.ShowClearAllFilters ? styles.ColGeneralFilterWithBtn : styles.ColGeneralFilterOnly}>
<WebPartTitle title={this.props.title} updateProperty={(value: string) => this.props.updateTitle(value)} displayMode={this.props.displayMode} placeholder={strings.WebPartTitlePlaceHolder}></WebPartTitle>
{this.state.errorMsg ?
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={false}
truncated={true}>
<b>{this.state.errorHeader}</b>{this.state.errorMsg}
</MessageBar> :
<React.Fragment>
{this.props.clickEnabled && !this.state.isModalHidden && this.state.selectedItem && this.GetOnClickAction()}
<div className={styles.rowTopInformation}>
{this.props.GeneralFilter &&
<div className={this.props.ShowClearAllFilters ? styles.ColGeneralFilterWithBtn : styles.ColGeneralFilterOnly}>
<Shimmer isDataLoaded={!this.state.isLoading} height={37}>
<SearchBox value={this.state.generalFilter} placeholder={this.props.GeneralFilterPlaceHolderText} onClear={() => this.clearGeneralFilter()} onChange={(ev, newValue) => this.filterListItemsByGeneralFilter(newValue, false, true, this.state.items, this.state.filterItems)} />
</div>}
<div className={styles.ColClearAll}>
{this.props.ShowClearAllFilters && clearAllButton}
</Shimmer>
</div>
}
<div className={styles.ColClearAll}>
<Shimmer isDataLoaded={!this.state.isLoading}>
{this.props.ShowClearAllFilters && clearAllButton}
</Shimmer>
</div>
<div className={styles.rowData}>
<div className={styles.colData}>
{this.props.ShowItemCount && <div className={styles.template_resultCount}>{this.props.ItemCountText.replace("{itemCount}", `${this.state.filterItems.length}`)}</div>}
<DetailsList
items={this._getItems()}
columns={this.state.columns}
groups={this.props.groupByField && this.groups}
groupProps={{
showEmptyGroups: true,
isAllGroupsCollapsed: true,
}}
onRenderDetailsFooter={this._checkIndividualFilter("footer") ? (detailsFooterProps) => this._onRenderDetails(detailsFooterProps) : undefined}
onRenderDetailsHeader={this._checkIndividualFilter("header") ? (detailsHeaderProps) => this._onRenderDetails(detailsHeaderProps) : undefined}
selectionMode={SelectionMode.none}
onItemInvoked={this.props.clickEnabled && !this.props.oneClickOption ? this._onItemInvoked : null}
onRenderRow={(props, defaultRender) => this.getOnRowClickRender(props, defaultRender)}
onRenderItemColumn={this._renderItemColumn}
/>
{this.props.ShowPagination &&
<div className={styles.paginationContainer}>
<div className={styles.paginationContainer__paginationContainer}>
<div className={`${styles.paginationContainer__paginationContainer__pagination}`}>
<div className={styles.standard}>
<Pagination
activePage={this.state.activePage}
firstPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='DoubleChevronLeft' />}
lastPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='DoubleChevronRight' />}
prevPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='ChevronLeft' />}
nextPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='ChevronRight' />}
activeLinkClass={styles.active}
itemsCountPerPage={this.props.ItemsInPage}
totalItemsCount={this.state.filterItems ? this.state.filterItems.length : 0}
pageRangeDisplayed={5}
onChange={this.handlePageChange.bind(this)}
/>
</div>
</div>
<div className={styles.rowData}>
<div className={styles.colData}>
{this.props.ShowItemCount &&
<Shimmer isDataLoaded={!this.state.isLoading} width={"25%"}><div className={styles.template_resultCount}>{this.props.ItemCountText.replace("{itemCount}", `${this.state.filterItems ? this.state.filterItems.length : 0}`)}</div></Shimmer>
}
<ShimmeredDetailsList
enableShimmer={this.state.isLoading}
items={this._getItems()}
columns={this.state.columns}
groups={!this.state.isLoading && this.props.groupByField && this.groups}
groupProps={{
showEmptyGroups: true,
isAllGroupsCollapsed: true,
}}
onRenderDetailsFooter={(detailsFooterProps) => this._onRenderDetails(detailsFooterProps, this._checkIndividualFilter("footer"), false)}
onRenderDetailsHeader={(detailsHeaderProps) => this._onRenderDetails(detailsHeaderProps, this._checkIndividualFilter("header"), true)}
selectionMode={SelectionMode.none}
onItemInvoked={this.props.clickEnabled && !this.props.oneClickOption ? this._onItemInvoked : null}
onRenderRow={(props, defaultRender) => this.getOnRowClickRender(props, defaultRender)}
onRenderItemColumn={this._renderItemColumn}
shimmerLines={this.props.ShowPagination ? this.props.ItemsInPage : 10}
/>
{this.props.ShowPagination &&
<div className={styles.paginationContainer}>
<div className={styles.paginationContainer__paginationContainer}>
<div className={`${styles.paginationContainer__paginationContainer__pagination}`}>
<div className={styles.standard}>
<Pagination
activePage={this.state.activePage}
firstPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='DoubleChevronLeft' />}
lastPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='DoubleChevronRight' />}
prevPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='ChevronLeft' />}
nextPageText={<Icon theme={this.props.themeVariant as ITheme} iconName='ChevronRight' />}
activeLinkClass={styles.active}
itemsCountPerPage={this.props.ItemsInPage}
totalItemsCount={this.state.filterItems ? this.state.filterItems.length : 0}
pageRangeDisplayed={5}
onChange={this.handlePageChange.bind(this)}
/>
</div>
</div>
</div>
}
</div>
</div>
}
</div>
</React.Fragment>}
</div>
</React.Fragment>}
</div>
</div>
</div >);
}
}

View File

@ -105,7 +105,9 @@ define([], function () {
InitialSearchText: "Initial search query",
GeneralFilterInitialQueryEnabled: "Initial search enabled",
GeneralFilterInitialQueryOption: "Initial search type",
GeneralFilterInitialQueryTextValue: "Initial search value"
GeneralFilterInitialQueryTextValue: "Initial search value",
WebPartTitlePlaceHolder: "Web part title",
ModerAudienceEnabledTitle: "Modern audience enabled",
ModernAudienceEnabledTitle: "Modern audience enabled",
}
});

View File

@ -105,6 +105,9 @@ declare interface IListSearchWebPartStrings {
GeneralFilterInitialQueryEnabled: string;
GeneralFilterInitialQueryOption: string;
GeneralFilterInitialQueryTextValue: string;
WebPartTitlePlaceHolder: string;
ModerAudienceEnabledTitle: string;
ModernAudienceEnabledTitle: string;
}
declare module 'ListSearchWebPartStrings' {

View File

@ -8,6 +8,7 @@ export interface IListData {
Query: string;
uniqueId: string;
sortIdx: number;
AudienceEnabled: boolean;
}
export interface IBaseFieldData {

View File

@ -2,17 +2,18 @@ import { SiteList } from "./IListConfigProps";
import { SharePointType } from "./ISharePointFieldTypes";
export interface IMapQuery {
[site: string]: Array<IMapQueryList>;
[site: string]: Array<IMapQueryList>;
}
export interface IMapQueryList {
[list: string]: Array<IListSearchListQuery>;
[list: string]: Array<IListSearchListQuery>;
}
export interface IListSearchListQuery {
list: SiteList;
camlQuery?: string;
viewName?: string;
fields: Array<{ originalField: string, newField: string, fieldType: SharePointType }>;
}
list: SiteList;
audienceEnabled: boolean;
camlQuery?: string;
viewName?: string;
fields: Array<{ originalField: string, newField: string, fieldType: SharePointType }>;
}

View File

@ -9,4 +9,9 @@ export default interface IResult {
UniqueId: string;
ServerUrl: string;
FileLeafRef: string;
}
OData__ModernAudienceTargetUserField:Audience[];
}
interface Audience{
Name: string;
}

View File

@ -0,0 +1,37 @@
import { graph } from "@pnp/graph";
import "@pnp/graph/users";
import "@pnp/graph/groups";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { PnPClientStorage } from "@pnp/common";
import IGraphService from "./IGraphService";
export default class GraphService implements IGraphService {
private _storage: PnPClientStorage;
private _localStorageKey: string = 'userGroups';
// 1 day
private _expiredTimeMillisecons: number = 8.64e+7;
public constructor(spfxContext: WebPartContext) {
graph.setup({
spfxContext
});
this._storage = new PnPClientStorage();
}
public async getTransitiveMemberOf(): Promise<string[]> {
try {
this._storage.local.deleteExpired();
let userGroups = this._storage.local.get(this._localStorageKey);
if (!userGroups) {
userGroups = await graph.me.getMemberGroups();
this._storage.local.put(this._localStorageKey, userGroups, new Date(new Date().getTime() + this._expiredTimeMillisecons));
}
return userGroups;
} catch (error) {
return Promise.reject(error);
}
}
}

View File

@ -0,0 +1,4 @@
export default interface IGraphService {
getTransitiveMemberOf(): Promise<Array<string>>;
}

View File

@ -1,8 +1,9 @@
import { ListField } from "../model/IListConfigProps";
import { IListSearchListQuery } from "../model/IMapQuery";
import GraphService from "./GraphService";
export default interface IListService {
getListItems(listQueryOptions: IListSearchListQuery, listPropertyName: string, sitePropertyName: string, sitePropertyValue: string, rowLimit: number): Promise<Array<any>>;
getListItems(listQueryOptions: IListSearchListQuery, listPropertyName: string, sitePropertyName: string, sitePropertyValue: string, rowLimit: number, graphService?: GraphService): Promise<Array<any>>;
getListItemById(listQueryOptions: IListSearchListQuery, itemId: number): Promise<any>;
getSiteListsTitle(): Promise<Array<any>>;
getListFields(listTitle: string): Promise<Array<ListField>>;

View File

@ -11,9 +11,10 @@ import XMLParser from 'react-xml-parser';
import { IWeb, Web } from '@pnp/sp/webs';
import { SharePointType } from '../model/ISharePointFieldTypes';
import IResult from '../model/IResult';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { intersection, isEmpty } from '@microsoft/sp-lodash-subset';
import { ListField, SiteList } from '../model/IListConfigProps';
import { IListSearchListQuery } from '../model/IMapQuery';
import GraphService from './GraphService';
export interface QueryHelperEntity {
@ -21,9 +22,12 @@ export interface QueryHelperEntity {
expandFields: string[];
}
export default class ListService implements IListService {
private web: IWeb;
private baseUrl: string;
private static SharePointOnlineAudienceOOTBFieldName = "OData__ModernAudienceTargetUserField";
public static MAX_TOP: number = 5000;
constructor(siteUrl: string, useCache: boolean, cacheTime?: number, cacheType?: "session" | "local") {
sp.setup({
@ -40,7 +44,102 @@ export default class ListService implements IListService {
this.baseUrl = siteUrl;
}
private GetViewFieldsWithId(listQueryOptions: IListSearchListQuery, isCamlQuery: boolean): QueryHelperEntity {
public async getListItems(listQueryOptions: IListSearchListQuery, listPropertyName: string, sitePropertyName: string, sitePropertyValue: string, rowLimit: number, graphService?: GraphService): Promise<Array<IResult>> {
try {
let camlQuery: boolean = false;
let items: any = undefined;
let queryConfig: QueryHelperEntity = this.GetViewFieldsWithId(listQueryOptions, !isEmpty(listQueryOptions.camlQuery) || !isEmpty(listQueryOptions.viewName), false);
if (listQueryOptions.camlQuery) {
let query = this.getCamlQueryWithViewFieldsAndRowLimit(listQueryOptions.camlQuery, queryConfig, rowLimit);
items = await this.getListItemsByCamlQuery(listQueryOptions.list.Id, query, queryConfig);
}
else {
if (listQueryOptions.viewName) {
let viewInfo: any = await this.web.lists.getById(listQueryOptions.list.Id).views.getByTitle(listQueryOptions.viewName).select("ViewQuery").get();
let query = this.getCamlQueryWithViewFieldsAndRowLimit(`<View><Query>${viewInfo.ViewQuery}</Query></View>`, queryConfig, rowLimit);
items = await this.getListItemsByCamlQuery(listQueryOptions.list.Id, query, queryConfig);
}
else {
items = await sp.web.lists.getById(listQueryOptions.list.Id).items
.select(queryConfig.viewFields.join(','))
.top(rowLimit || ListService.MAX_TOP)
.expand(queryConfig.expandFields.join(',')).usingCaching().get();
}
}
if (listQueryOptions.audienceEnabled && graphService) {
let userGroups: string[] = await graphService.getTransitiveMemberOf();
items = this.getAudienceItems(items, userGroups);
}
let mappedItems = items.map((i: IResult) => {
i.FileExtension = this.GetFileExtension(i.FileLeafRef);
i.SiteUrl = this.baseUrl;
i.ListName = listQueryOptions.list.Title;
i.List = listQueryOptions.list;
listQueryOptions.fields.map(field => {
i = this.GetItemValue(i, field, camlQuery);
});
if (listPropertyName) {
i[listPropertyName] = listQueryOptions.list.Title;
}
if (sitePropertyName) {
i[sitePropertyName] = sitePropertyValue;
}
return i;
});
return mappedItems;
} catch (error) {
return Promise.reject(error);
}
}
public async getListItemById(listQueryOptions: IListSearchListQuery, itemId: number): Promise<any> {
try {
let queryConfig: QueryHelperEntity = this.GetViewFieldsWithId(listQueryOptions, false, true);
return this.web.lists.getById(listQueryOptions.list.Id).items.getById(itemId).select(queryConfig.viewFields.join(',')).expand(queryConfig.expandFields.join(',')).usingCaching().get();
} catch (error) {
return Promise.reject(error);
}
}
public async getSiteListsTitle(): Promise<Array<SiteList>> {
try {
return this.web.lists.filter('Hidden eq false').select('Title,Id').get();
} catch (error) {
return Promise.reject(error);
}
}
public async getListFields(listId: string): Promise<Array<ListField>> {
try {
return this.web.lists.getById(listId).fields.select('EntityPropertyName,Title,InternalName,TypeAsString').get();
} catch (error) {
return Promise.reject(error);
}
}
private getAudienceItems(itemsToFilter: IResult[], userGroups: string[]) {
let results: IResult[] = [];
userGroups && itemsToFilter.map(item => {
let itemAudiencesIds: string[] = item.OData__ModernAudienceTargetUserField && item.OData__ModernAudienceTargetUserField.map(audience => { return audience.Name.split("|")[2]; });
if (itemAudiencesIds) {
let matches: string[] = intersection(itemAudiencesIds, userGroups);
if (matches && matches.length > 0) {
results.push(item);
}
}
else {
results.push(item);
}
});
return results;
}
private GetViewFieldsWithId(listQueryOptions: IListSearchListQuery, isCamlQuery: boolean, isItemId: boolean): QueryHelperEntity {
let result: QueryHelperEntity = { expandFields: [], viewFields: ['ServerUrl', 'FileLeafRef', 'Id', 'UniqueId'] };
let hasToAddFieldsAsText: boolean = false;
listQueryOptions.fields.map(field => {
@ -100,6 +199,11 @@ export default class ListService implements IListService {
result.expandFields.push('FieldValuesAsText');
}
if (listQueryOptions.audienceEnabled && !isItemId) {
result.expandFields.push(ListService.SharePointOnlineAudienceOOTBFieldName);
result.viewFields.push(`${ListService.SharePointOnlineAudienceOOTBFieldName}/Name`);
}
return result;
}
@ -186,90 +290,6 @@ export default class ListService implements IListService {
return item;
}
public async getListItems(listQueryOptions: IListSearchListQuery, listPropertyName: string, sitePropertyName: string, sitePropertyValue: string, rowLimit: number): Promise<Array<IResult>> {
try {
let camlQuery: boolean = false;
let items: any = undefined;
let queryConfig: QueryHelperEntity = this.GetViewFieldsWithId(listQueryOptions, !isEmpty(listQueryOptions.camlQuery) || !isEmpty(listQueryOptions.viewName));
if (listQueryOptions.camlQuery) {
let query = this.getCamlQueryWithViewFieldsAndRowLimit(listQueryOptions.camlQuery, queryConfig, rowLimit);
items = await this.getListItemsByCamlQuery(listQueryOptions.list.Id, query, queryConfig);
}
else {
if (listQueryOptions.viewName) {
let viewInfo: any = await this.web.lists.getById(listQueryOptions.list.Id).views.getByTitle(listQueryOptions.viewName).select("ViewQuery").get();
let query = this.getCamlQueryWithViewFieldsAndRowLimit(`<View><Query>${viewInfo.ViewQuery}</Query></View>`, queryConfig, rowLimit);
items = await this.getListItemsByCamlQuery(listQueryOptions.list.Id, query, queryConfig);
}
else {
if (rowLimit) {
if (queryConfig.expandFields && queryConfig.expandFields.length > 0) {
items = await this.web.lists.getById(listQueryOptions.list.Id).items.select(queryConfig.viewFields.join(',')).expand(queryConfig.expandFields.join(',')).usingCaching().get();
}
else {
items = await this.web.lists.getById(listQueryOptions.list.Id).items.top(rowLimit).select(queryConfig.viewFields.join(',')).usingCaching().get();
}
}
else {
if (queryConfig.expandFields && queryConfig.expandFields.length > 0) {
items = await this.web.lists.getById(listQueryOptions.list.Id).items.select(queryConfig.viewFields.join(',')).expand(queryConfig.expandFields.join(',')).usingCaching().get();
}
else {
items = await this.web.lists.getById(listQueryOptions.list.Id).items.select(queryConfig.viewFields.join(',')).usingCaching().get();
}
}
}
}
let mappedItems = items.map((i: IResult) => {
i.FileExtension = this.GetFileExtension(i.FileLeafRef);
i.SiteUrl = this.baseUrl;
i.ListName = listQueryOptions.list.Title;
i.List = listQueryOptions.list;
listQueryOptions.fields.map(field => {
i = this.GetItemValue(i, field, camlQuery);
});
if (listPropertyName) {
i[listPropertyName] = listQueryOptions.list.Title;
}
if (sitePropertyName) {
i[sitePropertyName] = sitePropertyValue;
}
return i;
});
return mappedItems;
} catch (error) {
return Promise.reject(error);
}
}
public async getListItemById(listQueryOptions: IListSearchListQuery, itemId: number): Promise<any> {
try {
let queryConfig: QueryHelperEntity = this.GetViewFieldsWithId(listQueryOptions, false);
return this.web.lists.getById(listQueryOptions.list.Id).items.getById(itemId).select(queryConfig.viewFields.join(',')).expand(queryConfig.expandFields.join(',')).usingCaching().get();
} catch (error) {
return Promise.reject(error);
}
}
public async getSiteListsTitle(): Promise<Array<SiteList>> {
try {
return this.web.lists.filter('Hidden eq false').select('Title,Id').get();
} catch (error) {
return Promise.reject(error);
}
}
public async getListFields(listId: string): Promise<Array<ListField>> {
try {
return this.web.lists.getById(listId).fields.select('EntityPropertyName,Title,InternalName,TypeAsString').get();
} catch (error) {
return Promise.reject(error);
}
}
private async getListItemsByCamlQuery(listId: string, camlQuery: string, queryConfig: QueryHelperEntity): Promise<Array<any>> {
try {
const caml: ICamlQuery = {