2020-12-07 19:23:18 +00:00
import React , { ReactNode , useEffect , useState } from "react" ;
import { useTranslation } from "react-i18next" ;
2021-02-17 21:12:25 +00:00
import { useErrorHandler } from "react-error-boundary" ;
2020-12-07 19:23:18 +00:00
import {
IAction ,
IActions ,
2021-01-26 19:39:01 +00:00
IActionsResolver ,
2020-12-07 19:23:18 +00:00
IFormatter ,
2021-03-18 08:07:26 +00:00
ITransform ,
2020-12-07 19:23:18 +00:00
Table ,
TableBody ,
TableHeader ,
TableVariant ,
} from "@patternfly/react-table" ;
import { Spinner } from "@patternfly/react-core" ;
import _ from "lodash" ;
2020-12-14 08:57:05 +00:00
import { PaginatingTableToolbar } from "./PaginatingTableToolbar" ;
2021-01-11 18:56:19 +00:00
import { asyncStateFetch } from "../../context/auth/AdminClient" ;
2021-03-16 12:42:05 +00:00
import { ListEmptyState } from "../list-empty-state/ListEmptyState" ;
2020-12-14 08:57:05 +00:00
2020-12-07 19:23:18 +00:00
type Row < T > = {
data : T ;
2020-12-14 08:57:05 +00:00
selected : boolean ;
2021-03-31 08:48:14 +00:00
disableSelection : boolean ;
2021-04-01 06:35:38 +00:00
disableActions : boolean ;
2020-12-14 08:57:05 +00:00
cells : ( keyof T | JSX . Element ) [ ] ;
2020-12-07 19:23:18 +00:00
} ;
type DataTableProps < T > = {
ariaLabelKey : string ;
columns : Field < T > [ ] ;
rows : Row < T > [ ] ;
actions? : IActions ;
2021-01-26 19:39:01 +00:00
actionResolver? : IActionsResolver ;
2020-12-14 08:57:05 +00:00
onSelect ? : ( isSelected : boolean , rowIndex : number ) = > void ;
canSelectAll : boolean ;
2020-12-07 19:23:18 +00:00
} ;
function DataTable < T > ( {
columns ,
rows ,
actions ,
2021-01-26 19:39:01 +00:00
actionResolver ,
2020-12-07 19:23:18 +00:00
ariaLabelKey ,
2020-12-14 08:57:05 +00:00
onSelect ,
canSelectAll ,
2021-04-01 14:14:19 +00:00
. . . props
2020-12-07 19:23:18 +00:00
} : DataTableProps < T > ) {
const { t } = useTranslation ( ) ;
return (
< Table
2021-04-01 14:14:19 +00:00
{ . . . props }
2020-12-07 19:23:18 +00:00
variant = { TableVariant . compact }
2020-12-14 08:57:05 +00:00
onSelect = {
onSelect
? ( _ , isSelected , rowIndex ) = > onSelect ( isSelected , rowIndex )
: undefined
}
canSelectAll = { canSelectAll }
2020-12-07 19:23:18 +00:00
cells = { columns . map ( ( column ) = > {
2021-04-01 13:41:21 +00:00
return { . . . column , title : t ( column . displayKey || column . name ) } ;
2020-12-07 19:23:18 +00:00
} ) }
rows = { rows }
actions = { actions }
2021-01-26 19:39:01 +00:00
actionResolver = { actionResolver }
2020-12-07 19:23:18 +00:00
aria - label = { t ( ariaLabelKey ) }
>
< TableHeader / >
< TableBody / >
< / Table >
) ;
}
export type Field < T > = {
name : string ;
displayKey? : string ;
cellFormatters? : IFormatter [ ] ;
2021-03-18 08:07:26 +00:00
transforms? : ITransform [ ] ;
2020-12-07 19:23:18 +00:00
cellRenderer ? : ( row : T ) = > ReactNode ;
} ;
export type Action < T > = IAction & {
onRowClick ? : ( row : T ) = > Promise < boolean > | void ;
} ;
export type DataListProps < T > = {
loader : ( first? : number , max? : number , search? : string ) = > Promise < T [ ] > ;
2020-12-14 08:57:05 +00:00
onSelect ? : ( value : T [ ] ) = > void ;
canSelectAll? : boolean ;
2021-03-31 08:48:14 +00:00
isRowDisabled ? : ( value : T ) = > boolean ;
2020-12-07 19:23:18 +00:00
isPaginated? : boolean ;
ariaLabelKey : string ;
2021-02-28 20:02:31 +00:00
searchPlaceholderKey? : string ;
2020-12-07 19:23:18 +00:00
columns : Field < T > [ ] ;
actions? : Action < T > [ ] ;
2021-01-26 19:39:01 +00:00
actionResolver? : IActionsResolver ;
2021-02-23 13:36:37 +00:00
searchTypeComponent? : ReactNode ;
2020-12-07 19:23:18 +00:00
toolbarItem? : ReactNode ;
emptyState? : ReactNode ;
} ;
2020-12-11 10:18:29 +00:00
/ * *
* A generic component that can be used to show the initial list most sections have . Takes care of the loading of the date and filtering .
* All you have to define is how the columns are displayed .
* @example
* < KeycloakDataTable columns = { [
* {
* name : "clientId" , //name of the field from the array of object the loader returns to display in this column
* displayKey : "clients:clientID" , //i18n key to use to lookup the name of the column header
* cellRenderer : ClientDetailLink , //optionally you can use a component to render the column when you don't want just the content of the field, the whole row / entire object is passed in.
* }
* ] }
2021-01-12 13:16:35 +00:00
* @param { DataListProps } props - The properties .
2020-12-11 10:18:29 +00:00
* @param { string } props . ariaLabelKey - The aria label key i18n key to lookup the label
* @param { string } props . searchPlaceholderKey - The i18n key to lookup the placeholder for the search box
* @param { boolean } props . isPaginated - if true the the loader will be called with first , max and search and a pager will be added in the header
* @param { ( first? : number , max? : number , search? : string ) = > Promise < T [ ] > } props . loader - loader function that will fetch the data to display first , max and search are only applicable when isPaginated = true
* @param { Field < T > } props . columns - definition of the columns
* @param { Action [ ] } props . actions - the actions that appear on the row
2021-01-26 19:39:01 +00:00
* @param { IActionsResolver } props . actionResolver Resolver for the given action
2021-01-12 13:16:35 +00:00
* @param { ReactNode } props . toolbarItem - Toolbar items that appear on the top of the table { @link ToolbarItem }
* @param { ReactNode } props . emptyState - ReactNode show when the list is empty could be any component but best to use { @link ListEmptyState }
2020-12-11 10:18:29 +00:00
* /
export function KeycloakDataTable < T > ( {
2020-12-07 19:23:18 +00:00
ariaLabelKey ,
searchPlaceholderKey ,
isPaginated = false ,
2020-12-14 08:57:05 +00:00
onSelect ,
canSelectAll = false ,
2021-03-31 08:48:14 +00:00
isRowDisabled ,
2020-12-07 19:23:18 +00:00
loader ,
columns ,
actions ,
2021-01-26 19:39:01 +00:00
actionResolver ,
2021-02-23 13:36:37 +00:00
searchTypeComponent ,
2020-12-07 19:23:18 +00:00
toolbarItem ,
emptyState ,
2021-04-01 14:14:19 +00:00
. . . props
2020-12-07 19:23:18 +00:00
} : DataListProps < T > ) {
const { t } = useTranslation ( ) ;
2021-03-24 14:07:49 +00:00
const [ selected , setSelected ] = useState < T [ ] > ( [ ] ) ;
2020-12-07 19:23:18 +00:00
const [ rows , setRows ] = useState < Row < T > [ ] > ( ) ;
2021-03-30 12:25:45 +00:00
const [ unPaginatedData , setUnPaginatedData ] = useState < T [ ] > ( ) ;
2020-12-07 19:23:18 +00:00
const [ filteredData , setFilteredData ] = useState < Row < T > [ ] > ( ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ max , setMax ] = useState ( 10 ) ;
const [ first , setFirst ] = useState ( 0 ) ;
2021-03-22 08:14:24 +00:00
const [ search , setSearch ] = useState < string > ( "" ) ;
2020-12-07 19:23:18 +00:00
2021-01-05 13:39:27 +00:00
const [ key , setKey ] = useState ( 0 ) ;
const refresh = ( ) = > setKey ( new Date ( ) . getTime ( ) ) ;
2021-02-17 21:12:25 +00:00
const handleError = useErrorHandler ( ) ;
2020-12-07 19:23:18 +00:00
useEffect ( ( ) = > {
2021-01-11 18:56:19 +00:00
return asyncStateFetch (
2021-01-05 13:39:27 +00:00
async ( ) = > {
setLoading ( true ) ;
2021-03-30 12:25:45 +00:00
let data = unPaginatedData || ( await loader ( first , max , search ) ) ;
if ( ! isPaginated ) {
setUnPaginatedData ( data ) ;
data = data . slice ( first , first + max ) ;
}
return convertToColumns ( data ) ;
2021-01-05 13:39:27 +00:00
} ,
( result ) = > {
setRows ( result ) ;
2021-01-29 10:33:18 +00:00
setFilteredData ( result ) ;
2021-01-05 13:39:27 +00:00
setLoading ( false ) ;
2021-02-17 21:12:25 +00:00
} ,
handleError
2021-01-05 13:39:27 +00:00
) ;
2021-03-22 08:14:24 +00:00
} , [ key , first , max , search ] ) ;
2020-12-07 19:23:18 +00:00
2020-12-14 08:57:05 +00:00
const getNodeText = ( node : keyof T | JSX . Element ) : string = > {
if ( [ "string" , "number" ] . includes ( typeof node ) ) {
return node ! . toString ( ) ;
}
if ( node instanceof Array ) {
return node . map ( getNodeText ) . join ( "" ) ;
}
if ( typeof node === "object" && node ) {
return getNodeText ( node . props . children ) ;
}
return "" ;
} ;
2021-03-30 12:25:45 +00:00
const convertToColumns = ( data : T [ ] ) = > {
return data ! . map ( ( value ) = > {
2021-04-01 06:35:38 +00:00
const disabledRow = isRowDisabled ? isRowDisabled ( value ) : false ;
2021-03-30 12:25:45 +00:00
return {
data : value ,
2021-04-01 06:35:38 +00:00
disableSelection : disabledRow ,
disableActions : disabledRow ,
2021-03-30 12:25:45 +00:00
selected : ! ! selected . find ( ( v ) = > ( v as any ) . id === ( value as any ) . id ) ,
cells : columns.map ( ( col ) = > {
if ( col . cellRenderer ) {
return col . cellRenderer ( value ) ;
}
return _ . get ( value , col . name ) ;
} ) ,
} ;
} ) ;
} ;
2020-12-07 19:23:18 +00:00
const filter = ( search : string ) = > {
setFilteredData (
2021-03-30 12:25:45 +00:00
convertToColumns ( unPaginatedData ! ) . filter ( ( row ) = >
2020-12-07 19:23:18 +00:00
row . cells . some (
( cell ) = >
2020-12-14 08:57:05 +00:00
cell &&
getNodeText ( cell ) . toLowerCase ( ) . includes ( search . toLowerCase ( ) )
2020-12-07 19:23:18 +00:00
)
)
) ;
2021-03-22 08:14:24 +00:00
setSearch ;
2020-12-07 19:23:18 +00:00
} ;
const convertAction = ( ) = >
actions &&
_ . cloneDeep ( actions ) . map ( ( action : Action < T > , index : number ) = > {
delete action . onRowClick ;
action . onClick = async ( _ , rowIndex ) = > {
2021-01-29 10:33:18 +00:00
const result = await actions [ index ] . onRowClick ! (
( filteredData || rows ) ! [ rowIndex ] . data
) ;
2020-12-07 19:23:18 +00:00
if ( result ) {
2021-01-05 13:39:27 +00:00
refresh ( ) ;
2020-12-07 19:23:18 +00:00
}
} ;
return action ;
} ) ;
const Loading = ( ) = > (
< div className = "pf-u-text-align-center" >
< Spinner / >
< / div >
) ;
2020-12-14 08:57:05 +00:00
const _onSelect = ( isSelected : boolean , rowIndex : number ) = > {
if ( rowIndex === - 1 ) {
setRows (
rows ! . map ( ( row ) = > {
row . selected = isSelected ;
return row ;
} )
) ;
} else {
rows ! [ rowIndex ] . selected = isSelected ;
setRows ( [ . . . rows ! ] ) ;
}
2021-03-24 14:07:49 +00:00
const difference = _ . differenceBy (
selected ,
rows ! . map ( ( row ) = > row . data ) ,
"id"
) ;
const selectedRows = [
. . . difference ,
. . . rows ! . filter ( ( row ) = > row . selected ) . map ( ( row ) = > row . data ) ,
] ;
setSelected ( selectedRows ) ;
onSelect ! ( selectedRows ) ;
2020-12-14 08:57:05 +00:00
} ;
2020-12-07 19:23:18 +00:00
return (
< >
2021-03-30 12:25:45 +00:00
{ rows && (
2020-12-07 19:23:18 +00:00
< PaginatingTableToolbar
count = { rows . length }
first = { first }
max = { max }
onNextClick = { setFirst }
onPreviousClick = { setFirst }
onPerPageSelect = { ( first , max ) = > {
setFirst ( first ) ;
setMax ( max ) ;
} }
2021-02-28 20:02:31 +00:00
inputGroupName = {
searchPlaceholderKey ? ` ${ ariaLabelKey } input ` : undefined
}
2021-03-30 12:25:45 +00:00
inputGroupOnEnter = {
isPaginated ? setSearch : ( search ) = > filter ( search )
2021-02-28 20:02:31 +00:00
}
inputGroupPlaceholder = { t ( searchPlaceholderKey || "" ) }
2021-02-23 13:36:37 +00:00
searchTypeComponent = { searchTypeComponent }
2021-03-30 12:25:45 +00:00
toolbarItem = { toolbarItem }
2020-12-07 19:23:18 +00:00
>
2021-03-16 12:42:05 +00:00
{ ! loading && ( filteredData || rows ) . length > 0 && (
2021-03-09 06:34:39 +00:00
< DataTable
2021-04-01 14:14:19 +00:00
{ . . . props }
2021-03-09 06:34:39 +00:00
canSelectAll = { canSelectAll }
onSelect = { onSelect ? _onSelect : undefined }
actions = { convertAction ( ) }
actionResolver = { actionResolver }
rows = { filteredData || rows }
columns = { columns }
ariaLabelKey = { ariaLabelKey }
/ >
) }
2021-04-01 14:14:19 +00:00
{ ! loading &&
rows . length === 0 &&
search !== "" &&
searchPlaceholderKey && (
< ListEmptyState
hasIcon = { true }
isSearchVariant = { true }
message = { t ( "noSearchResults" ) }
instructions = { t ( "noSearchResultsInstructions" ) }
/ >
) }
2021-03-09 06:34:39 +00:00
{ loading && < Loading / > }
2021-03-30 12:25:45 +00:00
< / PaginatingTableToolbar >
2020-12-07 19:23:18 +00:00
) }
2021-03-16 12:42:05 +00:00
< > { ! loading && rows ? . length === 0 && search === "" && emptyState } < / >
2020-12-07 19:23:18 +00:00
< / >
) ;
}