import * as React from 'react' ; import * as ReactDOM from 'react-dom' ; import { Fabric , ShimmeredDetailsList , IShimmeredDetailsListProps } from 'office-ui-fabric-react' ; import { ITooltipHostProps , TooltipHost , ITooltipStyles , Shimmer , ShimmerElementsGroup , ShimmerElementType , IShimmerElement , mergeStyleSets , ITheme , Selection } from 'office-ui-fabric-react' ; import * as Handlebars from 'handlebars' ; import { IReadonlyTheme } from '@microsoft/sp-component-base' ; import { BaseWebComponent , BuiltinTemplateSlots } from '@pnp/modern-search-extensibility' ; import { groupBy , sortBy , findIndex , isEmpty , cloneDeep } from "@microsoft/sp-lodash-subset" ; import { FileIcon } from '../components/FileIconComponent' ; import { DetailsListLayoutMode , SelectionMode , IColumn , IGroup , IDetailsRowProps , DetailsRow , IDetailsHeaderProps , DetailsHeader , CheckboxVisibility , DetailsRowCheck , IDetailsListCheckboxProps } from 'office-ui-fabric-react/lib/DetailsList' ; import { DEFAULT_CELL_STYLE_PROPS , DEFAULT_ROW_HEIGHTS } from 'office-ui-fabric-react/lib/components/DetailsList/DetailsRow.styles' ; import { IDataResultsTemplateContext } from '../models/common/ITemplateContext' ; import { ObjectHelper } from '../helpers/ObjectHelper' ; import * as DOMPurify from 'dompurify' ; import { DomPurifyHelper } from '../helpers/DomPurifyHelper' ; import { ITemplateService } from '../services/templateService/ITemplateService' ; import { TemplateService } from '../services/templateService/TemplateService' ; import { ServiceScope , ServiceKey } from "@microsoft/sp-core-library" ; const DEFAULT_SHIMMER_HEIGHT = 7 ; const SHIMMER_LINE_VS_CELL_WIDTH_RATIO = 0.95 ; const classNames = mergeStyleSets ( { fileIconHeaderIcon : { padding : 0 , fontSize : '16px' } , fileIconCell : { textAlign : 'center' , selectors : { '&:before' : { content : '.' , display : 'inline-block' , verticalAlign : 'middle' , height : '100%' , width : '0px' , visibility : 'hidden' } } } , fileIconImg : { verticalAlign : 'middle' , maxHeight : '16px' , maxWidth : '16px' } , controlWrapper : { display : 'flex' , flexWrap : 'wrap' } } ) ; export interface IDetailsListColumnConfiguration { /**
     * The name of the column
     */ name : string ; /**
     * The value of the column
     */ value : string ; /**
     * Alternate column for sorting
     */ valueSorting : string ; /**
     * Indicates if the value is an Handlebars expression
     */ useHandlebarsExpr : boolean ; /**
     * Column maximum width in px
     */ maxWidth : string ; /**
     * Column minimum width in px
     */ minWidth : string ; /**
     * Enable sorting on the column
     */ enableSorting : boolean ; /**
     * Enable column dynamic resize
     */ isResizable : boolean ; /**
     * Enable multiline column
     */ isMultiline : boolean ; } export interface IDetailsListComponentProps { /**
     * Current items
     */ items ? : { [ key ] : any ; } [ ] ; /**
     * The columns configuration
     */ columnsConfiguration ? : IDetailsListColumnConfiguration [ ] ; /**
     * Show the file icon or not in the first column
     */ showFileIcon ? : boolean ; /**
     * The field to use for the file extension ison display
     */ fileExtensionField ? : string ; /**
     * The field to use to know if the item is a container
     */ isContainerField ? : string ; /**
     * Enble the filtering on the columns
     */ enableFiltering ? : boolean ; /**
     * If true, the details list shimers are displayed
     */ showShimmers ? : boolean ; /**
     * If the details lsit should be compact
     */ isCompact ? : boolean ; /**
     * The current theme settings
     */ themeVariant ? : IReadonlyTheme ; /**
     * The field to group by
     */ groupBy ? : string ; /**
     * Show groups as collapsed by default if true. Expanded otherwise
     */ groupsCollapsed ? : boolean ; /**
     * The Handlebars context to inject in columns content (ex: @root)
     */ context ? : IDataResultsTemplateContext ; /**
     * The isolated Handlebars namespace
     */ handlebars : typeof Handlebars ; /**
    * The current selection keys. Needed to set the selected state of the component.
    */ selectedKeys ? : string [ ] ; /**
     * Flag indicating if items can be selected in the results
     */ allowItemSelection ? : boolean ; /**
    * Allow multiple values to be select=ed
    */ allowMulti ? : boolean ; } export interface IDetailsListComponentState { columns : IColumn [ ] ; items : any [ ] ; groups : IGroup [ ] ; } export class DetailsListComponent extends React . Component < IDetailsListComponentProps , IDetailsListComponentState > { private _allItems : any [ ] ; private _templateContext : IDataResultsTemplateContext ; private _domPurify : any ; private _selection : Selection ; private _selectionMode : SelectionMode = SelectionMode . none ; constructor ( props ) { super ( props ) ; this . _domPurify = DOMPurify . default ; this . _domPurify . setConfig ( { WHOLE_DOCUMENT : true } ) ; this . _domPurify . addHook ( 'uponSanitizeElement' , DomPurifyHelper . allowCustomComponentsHook ) ; this . _domPurify . addHook ( 'uponSanitizeAttribute' , DomPurifyHelper . allowCustomAttributesHook ) ; this . _allItems = this . props . items ? this . props . items : [ ] ; if ( ! isEmpty ( this . props . context ) ) { this . _selection = new Selection ( { getKey : ( item , index ) => { return item . key = `${ this . props . context . paging . currentPageNumber }${ index }` ; } } ) ; if ( this . props . allowItemSelection ) { this . _selectionMode = this . props . allowMulti ? SelectionMode . multiple : SelectionMode . single ; } this . _templateContext = this . props . context ; } const columns = [ ] ; if ( this . props . showFileIcon ) { columns . push ( { key : 'fileType' , name : 'File Type' , className : classNames . fileIconCell , iconClassName : classNames . fileIconHeaderIcon , iconName : 'Page' , isIconOnly : true , minWidth : 16 , maxWidth : 16 , onColumnClick : this . _onColumnClick , onRender : ( item ) => { const contentclass = ( ObjectHelper . byPath ( item , BuiltinTemplateSlots . ContentClass ) + "" ) . toLowerCase ( ) ; const fallbackSiteLogo = ! isEmpty ( contentclass ) && ( contentclass == "sts_site" || contentclass == "sts_web" ) ? ObjectHelper . byPath ( item , "SiteLogo" ) : "" ; return < FileIcon extension = { ObjectHelper . byPath ( item , this . props . fileExtensionField ) } isContainer = { ObjectHelper . byPath ( item , this . props . isContainerField ) } imageUrl = { fallbackSiteLogo } / > ; } } ) ; } if ( this . props . columnsConfiguration ) { this . props . columnsConfiguration . forEach ( ( column ) => { const allowSorting = column . enableSorting && ( column . useHandlebarsExpr !== true || ( column . valueSorting && column . valueSorting . length > 0 ) ) ; columns . push ( { key : column . name , name : column . name , fieldName : column . name , minWidth : parseInt ( column . minWidth ) , maxWidth : parseInt ( column . maxWidth ) , isRowHeader : true , isResizable : column . isResizable === true , isMultiline : column . isMultiline === true , isSorted : allowSorting , isSortedDescending : false , sortAscendingAriaLabel : 'Sorted A to Z' , sortDescendingAriaLabel : 'Sorted Z to A' , onColumnClick : allowSorting ? this . _onColumnClick : null , data : { useHandlebarsExpr : column . useHandlebarsExpr , value : column . valueSorting || column . value } , isPadded : true , onRender : ( item ) => { let value ; let renderColumnValue = null ; let hasError = false ; let toolTipText = column . useHandlebarsExpr ? value : '' ; if ( column . useHandlebarsExpr ) { try { value = this . props . handlebars ? this . _processHandleBarsExprValue ( column . value , item ) : column . value ; } catch ( error ) { hasError = true ; value = `<span style="color:red;font-style: italic" title="${ error . message }">${ `Error: ${ error . message }` }</span>` ; } } else { value = ObjectHelper . byPath ( item , column . value ) ; } renderColumnValue = < span title = { ! hasError ? toolTipText : '' } dangerouslySetInnerHTML = { { __html : this . _domPurify . sanitize ( value ) } } > < /span>; return renderColumnValue ; } , } ) ; } ) ; } this . state = { items : this . _allItems , columns : columns , groups : [ ] } ; this . _copyAndSort = this . _copyAndSort . bind ( this ) ; this . _onRenderCustomPlaceholder = this . _onRenderCustomPlaceholder . bind ( this ) ; this . _onRenderDetailsHeader = this . _onRenderDetailsHeader . bind ( this ) ; this . _onRenderRow = this . _onRenderRow . bind ( this ) ; } public render ( ) { const { columns , items } = this . state ; let shimmeredDetailsListProps = { theme : this . props . themeVariant as ITheme , items : items , compact : this . props . isCompact , columns : columns , selectionMode : this . _selectionMode , selection : this . _selection , layoutMode : DetailsListLayoutMode . justified , isHeaderVisible : true , enableShimmer : this . props . showShimmers , selectionPreservedOnEmptyClick : true , enterModalSelectionOnTouch : true , onRenderCustomPlaceholder : this . _onRenderCustomPlaceholder , onRenderRow : this . _onRenderRow , onRenderDetailsHeader : this . _onRenderDetailsHeader , } ; if ( this . state . groups . length > 0 ) { shimmeredDetailsListProps . groups = this . state . groups ; shimmeredDetailsListProps . groupProps = { showEmptyGroups : true } ; } return ( < Fabric > < ShimmeredDetailsList { ... shimmeredDetailsListProps } / > < /Fabric>); } public componentDidMount ( ) { if ( this . props . groupBy ) { const items = sortBy ( this . state . items , this . props . groupBy ) ; const groups = this . _buildGroups ( items , this . props . groupBy ) ; this . setState ( { groups : groups , items : items } ) ; } if ( this . props . selectedKeys ) { this . props . selectedKeys . forEach ( key => { this . _selection . setKeySelected ( key , true , false ) ; } ) ; this . forceUpdate ( ) ; } } private _onRenderCustomPlaceholder ( rowProps ) { const { columns , compact , selectionMode , checkboxVisibility , cellStyleProps = DEFAULT_CELL_STYLE_PROPS } = rowProps ; const { rowHeight , compactRowHeight } = DEFAULT_ROW_HEIGHTS ; const gapHeight = compact ? compactRowHeight : rowHeight + 1 ; const shimmerElementsRow = [ ] ; const showCheckbox = selectionMode !== SelectionMode . none && checkboxVisibility !== CheckboxVisibility . hidden ; if ( showCheckbox ) { shimmerElementsRow . push ( < ShimmerElementsGroup theme = { this . props . themeVariant as ITheme } backgroundColor = { this . props . themeVariant . semanticColors . bodyBackground } key = { 'checkboxGap' } shimmerElements = { [ { type : ShimmerElementType . gap , width : '40px' , height : gapHeight } ] } / > ) ; } columns . forEach ( ( column , columnIdx ) => { const shimmerElements = [ ] ; const groupWidth = cellStyleProps . cellLeftPadding + cellStyleProps . cellRightPadding + column . calculatedWidth ! + ( column . isPadded ? cellStyleProps . cellExtraRightPadding : 0 ) ; shimmerElements . push ( { type : ShimmerElementType . gap , width : cellStyleProps . cellLeftPadding , height : gapHeight } ) ; if ( column . isIconOnly ) { shimmerElements . push ( { type : ShimmerElementType . line , width : column . calculatedWidth ! , height : column . calculatedWidth ! } ) ; shimmerElements . push ( { type : ShimmerElementType . gap , width : cellStyleProps . cellRightPadding , height : gapHeight } ) ; } else { shimmerElements . push ( { type : ShimmerElementType . line , width : column . calculatedWidth ! * SHIMMER_LINE_VS_CELL_WIDTH_RATIO , height : DEFAULT_SHIMMER_HEIGHT } ) ; shimmerElements . push ( { type : ShimmerElementType . gap , width : cellStyleProps . cellRightPadding + ( column . calculatedWidth ! - column . calculatedWidth ! * SHIMMER_LINE_VS_CELL_WIDTH_RATIO ) + ( column . isPadded ? cellStyleProps . cellExtraRightPadding : 0 ) , height : gapHeight } ) ; } shimmerElementsRow . push ( < ShimmerElementsGroup theme = { this . props . themeVariant as ITheme } key = { columnIdx } width = { `${ groupWidth }px` } backgroundColor = { this . props . themeVariant . semanticColors . bodyBackground } shimmerElements = { shimmerElements } / > ) ; } ) ; shimmerElementsRow . push ( < ShimmerElementsGroup key = { 'endGap' } width = { '100%' } backgroundColor = { this . props . themeVariant . semanticColors . bodyBackground } theme = { this . props . themeVariant as ITheme } shimmerElements = { [ { type : ShimmerElementType . gap , width : '100%' , height : gapHeight } ] } / > ) ; return < Shimmer theme = { this . props . themeVariant as ITheme } customElementsGroup = { < div style = { { display : 'flex' } } > { shimmerElementsRow } < /div>}/ > ; } private _onRenderRow ( rowProps ) { return < DetailsRow { ... rowProps } theme = { this . props . themeVariant as ITheme } / > ; } private _onRenderDetailsHeader ( props ) { props . onRenderColumnHeaderTooltip = ( tooltipHostProps ) => { const customStyles = { } ; customStyles . root = { backgroundColor : this . props . themeVariant . semanticColors . listBackground , color : this . props . themeVariant . semanticColors . listText , selectors : { ':hover' : { backgroundColor : this . props . themeVariant . semanticColors . listHeaderBackgroundHovered } , i : { color : this . props . themeVariant . semanticColors . listText , } } } ; return < TooltipHost { ... tooltipHostProps } theme = { this . props . themeVariant as ITheme } styles = { customStyles } / > ; } ; return < DetailsHeader { ... props } theme = { this . props . themeVariant as ITheme } / > ; } private _onColumnClick = ( ev , column ) : void => { const { columns , items } = this . state ; let newItems = [ ] ; const newColumns = columns . slice ( ) ; const currColumn = newColumns . filter ( currCol => column . key === currCol . key ) [ 0 ] ; newColumns . forEach ( ( newCol ) => { if ( newCol === currColumn ) { currColumn . isSortedDescending = ! currColumn . isSortedDescending ; currColumn . isSorted = true ; } else { newCol . isSorted = false ; newCol . isSortedDescending = true ; } } ) ; if ( ! this . props . groupBy ) { newItems = this . _copyAndSort ( items , currColumn . data . value , currColumn . isSortedDescending ) ; } else { const groupedItems = groupBy ( items , ( i ) => { return ObjectHelper . byPath ( i , this . props . groupBy ) ; } ) ; for ( const group in groupedItems ) { const sortedItemsByGroup = this . _copyAndSort ( groupedItems [ group ] , currColumn . data . value , currColumn . isSortedDescending ) ; newItems = newItems . concat ( sortedItemsByGroup ) ; } } this . setState ( { columns : newColumns , items : newItems } ) ; } ; private _buildGroups ( items , groupByField ) { const groupedItems = groupBy ( items , ( i ) => { return ObjectHelper . byPath ( i , groupByField ) ; } ) ; let groups = [ ] ; for ( const group in groupedItems ) { const idx = findIndex ( items , ( i ) => { if ( group === 'undefined' ) { return ObjectHelper . byPath ( i , groupByField ) === undefined ; } else { return ObjectHelper . byPath ( i , groupByField ) === group ; } } ) ; let groupProps = { name : group , key : group , startIndex : idx , count : groupedItems [ group ] . length , isCollapsed : this . props . groupsCollapsed } ; groups . push ( groupProps ) ; } return groups ; } private _processHandleBarsExprValue ( columnValue , item ) { let exprValue = null ; const tempTemplateContent = `{{#with item as |item|}}${ columnValue }{{/with}}` ; let template = this . props . handlebars . compile ( tempTemplateContent ) ; exprValue = template ( { item : item } , { data : { root : { slots : this . _templateContext . slots , theme : this . _templateContext . theme , context : this . _templateContext . context , instanceId : this . _templateContext . instanceId } } } ) ; exprValue = exprValue ? exprValue . trim ( ) : null ; return exprValue ; } private _copyAndSort < T > ( items , columnKey , isSortedDescending ) { return items . slice ( 0 ) . sort ( ( a , b ) => { let aValue = ObjectHelper . byPath ( a , columnKey ) ; let bValue = ObjectHelper . byPath ( b , columnKey ) ; return ( ( isSortedDescending ? aValue < bValue : aValue > bValue ) ? 1 : - 1 ) ; } ) ; } } export class DetailsListWebComponent extends BaseWebComponent { public constructor ( ) { super ( ) ; } public connectedCallback ( ) { let props = this . resolveAttributes ( ) ; let serviceScope = this . _serviceScope ; let templateServiceKey = TemplateService . ServiceKey ; if ( props . instanceId ) { serviceScope = this . _webPartServiceScopes . get ( props . instanceId ) ? this . _webPartServiceScopes . get ( props . instanceId ) : serviceScope ; templateServiceKey = this . _webPartServiceKeys . get ( props . instanceId ) ? this . _webPartServiceKeys . get ( props . instanceId ) . TemplateService : templateServiceKey ; } const templateService = serviceScope . consume < ITemplateService > ( templateServiceKey ) ; const detailsListComponent = < DetailsListComponent { ... props } handlebars = { templateService . Handlebars } / > ; ReactDOM . render ( detailsListComponent , this ) ; } }