From 45cfb3ed59448950abb69b594840af679fa330ff Mon Sep 17 00:00:00 2001 From: Martin Muzikar Date: Thu, 15 Aug 2024 13:00:51 +0200 Subject: [PATCH] refactor(ui): Refactor paginated tables into one component --- packages/hawtio/src/plugins/logs/Logs.tsx | 315 ++++++---------- .../src/plugins/runtime/SysProps.test.tsx | 2 +- .../hawtio/src/plugins/runtime/SysProps.tsx | 257 ++----------- .../hawtio/src/plugins/runtime/Threads.tsx | 342 +++--------------- packages/hawtio/src/ui/index.ts | 1 + packages/hawtio/src/ui/page/HawtioHeader.css | 2 +- .../hawtio/src/ui/util/ExpandableText.tsx | 309 ++++++++++++++++ packages/hawtio/src/ui/util/FilteredTable.tsx | 328 +++++++++++++++++ packages/hawtio/src/ui/util/index.ts | 2 + 9 files changed, 824 insertions(+), 734 deletions(-) create mode 100644 packages/hawtio/src/ui/util/ExpandableText.tsx create mode 100644 packages/hawtio/src/ui/util/FilteredTable.tsx create mode 100644 packages/hawtio/src/ui/util/index.ts diff --git a/packages/hawtio/src/plugins/logs/Logs.tsx b/packages/hawtio/src/plugins/logs/Logs.tsx index 6f1d30cca..1f2f17557 100644 --- a/packages/hawtio/src/plugins/logs/Logs.tsx +++ b/packages/hawtio/src/plugins/logs/Logs.tsx @@ -1,5 +1,4 @@ import { - Bullseye, Button, Card, CardBody, @@ -11,37 +10,23 @@ import { DescriptionListGroup, DescriptionListTerm, Divider, - EmptyState, - EmptyStateBody, - EmptyStateIcon, Label, Modal, PageSection, - Pagination, - PaginationProps, - Panel, - SearchInput, Skeleton, Title, - Toolbar, - ToolbarContent, ToolbarFilter, - ToolbarGroup, - ToolbarItem, - EmptyStateHeader, - EmptyStateFooter, MenuToggle, MenuToggleElement, SelectOption, Select, SelectList, } from '@patternfly/react-core' -import { SearchIcon } from '@patternfly/react-icons' -import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table' -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { log } from './globals' import { LogEntry, LogFilter } from './log-entry' import { LOGS_UPDATE_INTERVAL, logsService } from './logs-service' +import { FilteredTable } from '@hawtiosrc/ui' export const Logs: React.FunctionComponent = () => { return ( @@ -57,6 +42,22 @@ export const Logs: React.FunctionComponent = () => { ) } +class LogRowData { + timestamp: string + level: string + logger: string + message: string + logEntry: LogEntry + + constructor(entry: LogEntry) { + this.timestamp = entry.getTimestamp() + this.level = entry.event.level + this.logger = entry.event.logger + this.message = entry.event.message + this.logEntry = entry + } +} + const LogsTable: React.FunctionComponent = () => { const [logs, setLogs] = useState([]) const timestamp = useRef(0) @@ -67,18 +68,17 @@ const LogsTable: React.FunctionComponent = () => { const [filters, setFilters] = useState(emptyFilters) // Temporal filter values holder until applying it const tempFilters = useRef<{ logger: string; message: string; properties: string }>(emptyFilters) - const [filteredLogs, setFilteredLogs] = useState([]) const [isSelectLevelOpen, setIsSelectLevelOpen] = useState(false) - // Pagination - const [page, setPage] = useState(1) - const [perPage, setPerPage] = useState(10) - const [paginatedLogs, setPaginatedLogs] = useState(filteredLogs.slice(0, perPage)) - // Modal const [isModalOpen, setIsModalOpen] = useState(false) const [selected, setSelected] = useState(null) + const rows = useMemo(() => { + console.log('filtering using ', filters) + return logsService.filter(logs, filters).map(log => new LogRowData(log)) + }, [logs, filters, tempFilters]) + useEffect(() => { const loadLogs = async () => { const result = await logsService.loadLogs() @@ -110,16 +110,6 @@ const LogsTable: React.FunctionComponent = () => { return () => timeoutHandle && clearTimeout(timeoutHandle) }, []) - useEffect(() => { - const filteredLogs = logsService.filter(logs, filters) - setFilteredLogs(filteredLogs) - }, [logs, filters]) - - useEffect(() => { - setPaginatedLogs(filteredLogs.slice(0, perPage)) - setPage(1) - }, [filteredLogs, perPage]) - if (!loaded) { return } @@ -142,130 +132,8 @@ const LogsTable: React.FunctionComponent = () => { }) } - const applyFilters = () => { - setFilters(prev => ({ ...prev, ...tempFilters.current })) - } - - const clearAllFilters = () => { - setFilters(emptyFilters) - tempFilters.current = emptyFilters - } - - const handleSetPage = ( - _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, - newPage: number, - _perPage?: number, - startIdx?: number, - endIdx?: number, - ) => { - setPaginatedLogs(filteredLogs.slice(startIdx, endIdx)) - setPage(newPage) - } - - const handlePerPageSelect = ( - _event: React.MouseEvent | React.KeyboardEvent | MouseEvent, - newPerPage: number, - newPage: number, - startIdx?: number, - endIdx?: number, - ) => { - setPaginatedLogs(filteredLogs.slice(startIdx, endIdx)) - setPage(newPage) - setPerPage(newPerPage) - } - - const renderPagination = (variant: PaginationProps['variant'], isCompact: boolean) => ( - - ) - const logLevels = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] - const tableToolbar = ( - - - - - handleFiltersChange( - 'level', - filters.level.filter(l => l !== chip), - ) - } - deleteChipGroup={() => handleFiltersChange('level', [])} - categoryName='Level' - > - - - - handleFiltersChange('logger', value)} - onSearch={() => applyFilters()} - onClear={() => handleFiltersChange('logger', '', true)} - /> - - - handleFiltersChange('message', value)} - onSearch={() => applyFilters()} - onClear={() => handleFiltersChange('message', '', true)} - /> - - - handleFiltersChange('properties', value)} - onSearch={() => applyFilters()} - onClear={() => handleFiltersChange('properties', '', true)} - /> - - - {renderPagination('top', true)} - - - ) - const selectLog = (log: LogEntry) => { setSelected(log) handleModalToggle() @@ -275,67 +143,88 @@ const LogsTable: React.FunctionComponent = () => { setIsModalOpen(!isModalOpen) } - const highlightSearch = (text: string, search: string) => { - if (search === '') { - return text - } - const lowerCaseSearch = search.toLowerCase() - const res = text - .split(new RegExp(`(${search})`, 'gi')) - .map((s, i) => (s.toLowerCase() === lowerCaseSearch ? {s} : s)) - return res - } - - return ( - - {tableToolbar} - - - - - - - - - - - {paginatedLogs.map((log, index) => ( - selectLog(log)}> - - - - - + const levelToggles = ( + + handleFiltersChange( + 'level', + filters.level.filter(l => l !== chip), + ) + } + deleteChipGroup={() => handleFiltersChange('level', [])} + categoryName='Level' + > + - - - )} - -
TimestampLevelLoggerMessage
{log.getTimestamp()} - - {highlightSearch(log.event.logger, filters.logger)}{highlightSearch(log.event.message, filters.message)}
- - - } - headingLevel='h2' - /> - Clear all filters and try again. - - - - - -
- {renderPagination('bottom', false)} + + + + ) + + const newTable = ( + <> -
+ , + }, + { + name: 'Logger', + key: 'logger', + }, + { + name: 'Message', + key: 'message', + }, + ]} + searchCategories={[ + { + name: 'Logger', + key: 'logger', + }, + { + name: 'Message', + key: 'message', + }, + ]} + onClick={row => { + setSelected(row.logEntry) + setIsModalOpen(true) + }} + extraToolbar={levelToggles} + /> + ) + + return newTable } const LogModal: React.FunctionComponent<{ diff --git a/packages/hawtio/src/plugins/runtime/SysProps.test.tsx b/packages/hawtio/src/plugins/runtime/SysProps.test.tsx index fd37c84fc..41fa4678a 100644 --- a/packages/hawtio/src/plugins/runtime/SysProps.test.tsx +++ b/packages/hawtio/src/plugins/runtime/SysProps.test.tsx @@ -79,7 +79,7 @@ describe('SysProps.tsx', () => { await waitFor(() => { expect(screen.getByText('value1')).toBeInTheDocument() }) - await changeOrder('name-header') + await changeOrder('key-header') testProperty(0, getMockedProperties()[2] as SystemProperty) testProperty(2, getMockedProperties()[0] as SystemProperty) await changeOrder('value-header') diff --git a/packages/hawtio/src/plugins/runtime/SysProps.tsx b/packages/hawtio/src/plugins/runtime/SysProps.tsx index e55e0fe36..3042efbe3 100644 --- a/packages/hawtio/src/plugins/runtime/SysProps.tsx +++ b/packages/hawtio/src/plugins/runtime/SysProps.tsx @@ -1,248 +1,39 @@ -import { objectSorter } from '@hawtiosrc/util/objects' -import { - Bullseye, - Dropdown, - DropdownItem, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - FormGroup, - Pagination, - Panel, - PanelHeader, - PanelMain, - PanelMainBody, - SearchInput, - Toolbar, - ToolbarContent, - ToolbarFilter, - ToolbarGroup, - ToolbarItem, - EmptyStateHeader, - MenuToggleElement, - MenuToggle, - DropdownList, -} from '@patternfly/react-core' -import { SearchIcon } from '@patternfly/react-icons' -import { Table, Tbody, Td, Th, ThProps, Thead, Tr } from '@patternfly/react-table' import React, { useEffect, useState } from 'react' import { runtimeService } from './runtime-service' -import { SystemProperty } from './types' +import { FilteredTable } from '@hawtiosrc/ui' export const SysProps: React.FunctionComponent = () => { const [properties, setProperties] = useState<{ key: string; value: string }[]>([]) - const [filteredProperties, setFilteredProperties] = useState([]) - const [page, setPage] = useState(1) - const [perPage, setPerPage] = useState(20) - const [searchTerm, setSearchTerm] = useState('') - const [filters, setFilters] = useState([]) - const [filteredAttribute, setFilteredAttribute] = useState('name') - const [sortIndex, setSortIndex] = React.useState(-1) - const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc') - const [isDropdownOpen, setIsDropdownOpen] = useState(false) useEffect(() => { runtimeService.loadSystemProperties().then(props => { setProperties(props) - setFilteredProperties(props) }) }, []) - useEffect(() => { - //filter with findTerm - let filtered: SystemProperty[] = [...properties] - - //add current search word to filters and filter - ;[...filters, `${filteredAttribute}:${searchTerm}`].forEach(value => { - const attr = value.split(':')[0] ?? '' - const searchTerm = value.split(':')[1] ?? '' - filtered = filtered.filter(prop => - (attr === 'name' ? prop.key : prop.value).toLowerCase().includes(searchTerm.toLowerCase()), - ) - }) - - setPage(1) - setFilteredProperties([...filtered]) - }, [searchTerm, properties, filters, filteredAttribute]) - - const onDeleteFilter = (filter: string) => { - if (`${filteredAttribute}:${searchTerm}` === filter) { - setSearchTerm('') - } else { - const newFilters = filters.filter(f => f !== filter) - setFilters(newFilters) - } - } - - const addToFilters = () => { - if (searchTerm !== '') { - setFilters([...filters, `${filteredAttribute}:${searchTerm}`]) - setSearchTerm('') - } - } - const clearFilters = () => { - setFilters([]) - setSearchTerm('') - } - - const PropsPagination = () => { - return ( - setPage(value)} - onPerPageSelect={(_evt, value) => { - setPerPage(value) - setPage(1) - }} - variant='top' - /> - ) - } - - const getPageProperties = (): SystemProperty[] => { - const start = (page - 1) * perPage - const end = start + perPage - return filteredProperties.slice(start, end) - } - - const attributes = [ - { key: 'name', value: 'Name' }, - { key: 'value', value: 'Value' }, - ] - - const dropdownItems = attributes.map(a => ( - { - setFilteredAttribute(a.key) - }} - key={a.key} - > - {a.value} - - )) - - const getSortParams = (sortColumn: number): ThProps['sort'] => ({ - sortBy: { - index: sortIndex, - direction: sortDirection, - defaultDirection: 'asc', // starting sort direction when first sorting a column. Defaults to 'asc' - }, - onSort: (_event, index, direction) => { - setSortIndex(index) - setSortDirection(direction) - }, - columnIndex: sortColumn, - }) - - const sortProperties = (): SystemProperty[] => { - let sortedProps = filteredProperties - if (sortIndex >= 0) { - sortedProps = filteredProperties.sort((a, b) => { - const aValue = sortIndex === 1 ? a.value : a.key - const bValue = sortIndex === 1 ? b.value : b.key - return objectSorter(aValue, bValue, sortDirection === 'desc') - }) - } - - return sortedProps - } - - const tableToolbar = ( - - - - { - setIsDropdownOpen(false) - addToFilters() - }} - defaultValue='name' - onOpenChange={setIsDropdownOpen} - toggle={(toggleRef: React.Ref) => ( - setIsDropdownOpen(!isDropdownOpen)} - isExpanded={isDropdownOpen} - > - {attributes.find(att => att.key === filteredAttribute)?.value} - - )} - shouldFocusToggleOnSelect - isOpen={isDropdownOpen} - > - {dropdownItems} - - onDeleteFilter(filter as string)} - deleteChipGroup={clearFilters} - categoryName='Filters' - > - setSearchTerm(value)} - aria-label='Search input' - /> - - - - - - - - - ) - return ( - - {tableToolbar} - - - {sortProperties().length > 0 && ( - - - - - - - - - - {getPageProperties().map((prop, index) => { - return ( - - - - - ) - })} - -
- Property Name - - Property Value -
{prop.key}{prop.value}
-
- )} - {filteredProperties.length === 0 && ( - - - } /> - No results found. - - - )} -
-
-
+ ) } diff --git a/packages/hawtio/src/plugins/runtime/Threads.tsx b/packages/hawtio/src/plugins/runtime/Threads.tsx index 59b81d867..b1b618108 100644 --- a/packages/hawtio/src/plugins/runtime/Threads.tsx +++ b/packages/hawtio/src/plugins/runtime/Threads.tsx @@ -1,39 +1,10 @@ -import { - Bullseye, - Button, - CodeBlock, - CodeBlockCode, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - FormGroup, - Modal, - Pagination, - Panel, - PanelHeader, - PanelMain, - PanelMainBody, - SearchInput, - Toolbar, - ToolbarContent, - ToolbarFilter, - ToolbarGroup, - ToolbarItem, - EmptyStateHeader, - DropdownItem, - MenuToggle, - MenuToggleElement, - Dropdown, - DropdownList, -} from '@patternfly/react-core' +import { Button, CodeBlock, CodeBlockCode, Modal, ToolbarGroup, ToolbarItem } from '@patternfly/react-core' import React, { useEffect, useState } from 'react' import { Thread } from './types' -import { objectSorter } from '@hawtiosrc/util/objects' -import { SearchIcon } from '@patternfly/react-icons' -import { Table, Tbody, Td, Th, Thead, ThProps, Tr } from '@patternfly/react-table' import { runtimeService } from './runtime-service' -import { ThreadInfoModal, ThreadState } from './ThreadInfoModal' +import { ThreadInfoModal } from './ThreadInfoModal' +import { FilteredTable } from '@hawtiosrc/ui' const ThreadsDumpModal: React.FunctionComponent<{ isOpen: boolean @@ -69,156 +40,24 @@ const ThreadsDumpModal: React.FunctionComponent<{ export const Threads: React.FunctionComponent = () => { const [threads, setThreads] = useState([]) - const [filteredThreads, setFilteredThreads] = useState([]) - - const [page, setPage] = useState(1) - const [perPage, setPerPage] = useState(20) - const [searchTerm, setSearchTerm] = useState('') - const [filters, setFilters] = useState([]) - const [attributeMenuItem, setAttributeMenuItem] = useState('Name') - const [sortIndex, setSortIndex] = React.useState(-1) - const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc') - const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [currentThread, setCurrentThread] = useState() const [isThreadsDumpModalOpen, setIsThreadsDumpModalOpen] = useState(false) const [isThreadDetailsOpen, setIsThreadDetailsOpen] = useState(false) - const [currentThread, setCurrentThread] = useState() const [threadConnectionMonitoring, setThreadConnectionMonitoring] = useState(false) useEffect(() => { const readThreads = async () => { const threads = await runtimeService.loadThreads() setThreads(threads) - setFilteredThreads(threads) setThreadConnectionMonitoring(await runtimeService.isThreadContentionMonitoringEnabled()) runtimeService.registerLoadThreadsRequest(threads => { setThreads(threads) - setFilteredThreads(threads) }) } readThreads() return () => runtimeService.unregisterAll() }, []) - useEffect(() => { - let filtered: Thread[] = [...threads] - - //add current searchTerm and filter - ;[...filters, `${attributeMenuItem}:${searchTerm}`].forEach(value => { - const attr = value.split(':')[0] ?? '' - const searchTerm = value.split(':')[1] ?? '' - filtered = filtered.filter(thread => - (attr === 'Name' ? thread.threadName : String(thread.threadState)) - .toLowerCase() - .includes(searchTerm.toLowerCase()), - ) - }) - - setPage(1) - setFilteredThreads([...filtered]) - }, [threads, searchTerm, attributeMenuItem, filters]) - - const onDeleteFilter = (filter: string) => { - if (`${attributeMenuItem}:${searchTerm}` === filter) { - setSearchTerm('') - } else { - const newFilters = filters.filter(f => f !== filter) - setFilters(newFilters) - } - } - - const addToFilters = () => { - if (searchTerm !== '') { - setFilters([...filters, `${attributeMenuItem}:${searchTerm}`]) - setSearchTerm('') - } - } - - const clearFilters = () => { - setFilters([]) - setSearchTerm('') - } - - const PropsPagination = () => { - return ( - setPage(value)} - onPerPageSelect={(_evt, value) => { - setPerPage(value) - setPage(1) - }} - variant='top' - /> - ) - } - - const getThreadsPage = (): Thread[] => { - const start = (page - 1) * perPage - const end = start + perPage - return filteredThreads.slice(start, end) - } - - const tableColumns = [ - { key: 'threadId', value: 'ID' }, - { key: 'threadState', value: 'State' }, - { key: 'threadName', value: 'Name' }, - { key: 'waitedTime', value: 'Waited Time' }, - { key: 'blockedTime', value: 'Blocked Time' }, - { key: 'inNative', value: 'Native' }, - { key: 'suspended', value: 'Suspended' }, - ] - - const dropdownItems = [ - { - setAttributeMenuItem('Name') - }} - key={'name-key'} - > - Name - , - { - setAttributeMenuItem('State') - }} - key={'state'} - > - State - , - ] - - const getIndexedThread = (thread: Thread): (string | number)[] => { - const { suspended, blockedTime, inNative, threadId, threadName, threadState, waitedTime } = thread - return [threadId, threadState, threadName, waitedTime, blockedTime, String(inNative), String(suspended)] - } - - const getSortParams = (sortColumn: number): ThProps['sort'] => ({ - sortBy: { - index: sortIndex, - direction: sortDirection, - defaultDirection: 'asc', // starting sort direction when first sorting a column. Defaults to 'asc' - }, - onSort: (_event, index, direction) => { - setSortIndex(index) - setSortDirection(direction) - }, - columnIndex: sortColumn, - }) - - const sortThreads = (): Thread[] => { - let sortedThreads = filteredThreads - if (sortIndex >= 0) { - sortedThreads = filteredThreads.sort((a, b) => { - const aValue = getIndexedThread(a)[sortIndex] - const bValue = getIndexedThread(b)[sortIndex] - return objectSorter(aValue, bValue, sortDirection === 'desc') - }) - } - return sortedThreads - } - const onThreadDumpClick = () => { setIsThreadsDumpModalOpen(true) } @@ -228,132 +67,63 @@ export const Threads: React.FunctionComponent = () => { setThreadConnectionMonitoring(!threadConnectionMonitoring) } - const tableToolbar = ( - - - - { - setIsDropdownOpen(false) - addToFilters() - }} - onOpenChange={setIsDropdownOpen} - defaultValue='Name' - toggle={(toggleRef: React.Ref) => ( - setIsDropdownOpen(!isDropdownOpen)} - > - {tableColumns.find(att => att.value === attributeMenuItem)?.value} - - )} - isOpen={isDropdownOpen} - > - {dropdownItems} - - onDeleteFilter(filter as string)} - deleteChipGroup={clearFilters} - categoryName='Filters' - > - setSearchTerm(value)} - aria-label='Search input' - /> - - - - - - - - - - - + const DetailsButton = (thread: Thread) => ( + + ) - - - - - + const ExtraToolBar = () => ( + + + + + + + + ) return ( - + <> - {tableToolbar} - - - {sortThreads().length > 0 && ( - - - - - {tableColumns.map((att, index) => ( - - ))} - - - - {getThreadsPage().map((thread, index) => { - return ( - - {tableColumns.map((att, column) => ( - - ))} - - - ) - })} - -
- {att.value} - -
- {att.key === 'threadState' ? ( - - ) : ( - getIndexedThread(thread)[column] - )} - - -
-
- )} - {filteredThreads.length === 0 && ( - - - } /> - No results found. - - - )} -
-
-
+ + } + /> + ) } diff --git a/packages/hawtio/src/ui/index.ts b/packages/hawtio/src/ui/index.ts index 437e2a5f2..b7e3f2e98 100644 --- a/packages/hawtio/src/ui/index.ts +++ b/packages/hawtio/src/ui/index.ts @@ -1 +1,2 @@ export { HawtioLoadingPage } from './page' +export { FilteredTable, ExpandableText } from './util' diff --git a/packages/hawtio/src/ui/page/HawtioHeader.css b/packages/hawtio/src/ui/page/HawtioHeader.css index d2405bdfc..4c22cd49a 100644 --- a/packages/hawtio/src/ui/page/HawtioHeader.css +++ b/packages/hawtio/src/ui/page/HawtioHeader.css @@ -26,4 +26,4 @@ .pf-v5-c-menu__item a { text-decoration: none; color: unset; -} \ No newline at end of file +} diff --git a/packages/hawtio/src/ui/util/ExpandableText.tsx b/packages/hawtio/src/ui/util/ExpandableText.tsx new file mode 100644 index 000000000..df8477e71 --- /dev/null +++ b/packages/hawtio/src/ui/util/ExpandableText.tsx @@ -0,0 +1,309 @@ +import * as React from 'react' +import styles from '@patternfly/react-styles/css/components/ExpandableSection/expandable-section' +import { css } from '@patternfly/react-styles' +import { c_expandable_section_m_truncate__content_LineClamp as lineClamp } from '@patternfly/react-tokens' +import { debounce, getResizeObserver, getUniqueId, PickOptional } from '@patternfly/react-core' +import { isString } from '@hawtiosrc/util/objects' +import { AngleRightIcon } from '@patternfly/react-icons' + +export enum ExpandableSectionVariant { + default = 'default', + truncate = 'truncate', +} + +//TODO: this is "borrowed" from patternfly, the whole ExpandableSection component can be fixed once https://github.com/patternfly/patternfly-react/pull/10870 gets merged and released +/** The main expandable section component. */ + +export interface ExpandableSectionProps extends React.HTMLProps { + /** Content rendered inside the expandable section. */ + children?: React.ReactNode + /** Additional classes added to the expandable section. */ + className?: string + /** Id of the content of the expandable section. When passing in the isDetached property, this + * property's value should match the contenId property of the expandable section toggle sub-component. + */ + contentId?: string + /** Id of the toggle of the expandable section, which provides an accessible name to the + * expandable section content via the aria-labelledby attribute. When the isDetached property + * is also passed in, the value of this property must match the toggleId property of the + * expandable section toggle sub-component. + */ + toggleId?: string + /** Display size variant. Set to "lg" for disclosure styling. */ + displaySize?: 'default' | 'lg' + /** Forces active state. */ + isActive?: boolean + /** Indicates the expandable section has a detached toggle. */ + isDetached?: boolean + /** Flag to indicate if the content is expanded. */ + isExpanded?: boolean + /** Flag to indicate if the content is indented. */ + isIndented?: boolean + /** Flag to indicate the width of the component is limited. Set to "true" for disclosure styling. */ + isWidthLimited?: boolean + /** Callback function to toggle the expandable section. Detached expandable sections should + * use the onToggle property of the expandable section toggle sub-component. + */ + onToggle?: (event: React.MouseEvent, isExpanded: boolean) => void + /** React node that appears in the attached toggle in place of the toggleText property. */ + toggleContent?: React.ReactNode + /** Text that appears in the attached toggle. */ + toggleText?: string + /** Text that appears in the attached toggle when collapsed (will override toggleText if + * both are specified; used for uncontrolled expandable with dynamic toggle text). + */ + toggleTextCollapsed?: string + /** Text that appears in the attached toggle when expanded (will override toggleText if + * both are specified; used for uncontrolled expandable with dynamic toggle text). + */ + toggleTextExpanded?: string + /** @beta Truncates the expandable content to the specified number of lines when using the + * "truncate" variant. + */ + truncateMaxLines?: number + /** @beta Determines the variant of the expandable section. When passing in "truncate" as the + * variant, the expandable content will be truncated after 3 lines by default. + */ + variant?: 'default' | 'truncate' +} + +interface ExpandableSectionState { + isExpanded: boolean + hasToggle: boolean + previousWidth?: number +} + +const setLineClamp = (lines: number, element: HTMLDivElement) => { + if (!element || lines < 1) { + return + } + + element.style.setProperty(lineClamp.name, lines.toString()) +} + +class ExpandableSection extends React.Component { + static displayName = 'ExpandableSection' + constructor(props: ExpandableSectionProps) { + super(props) + + this.state = { + isExpanded: props.isExpanded || false, + hasToggle: true, + previousWidth: undefined, + } + } + + expandableContentRef = React.createRef() + observer: any = () => {} + + static defaultProps: PickOptional = { + className: '', + toggleText: '', + toggleTextExpanded: '', + toggleTextCollapsed: '', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onToggle: (event, isExpanded): void => undefined, + isActive: false, + isDetached: false, + displaySize: 'default', + isWidthLimited: false, + isIndented: false, + variant: 'default', + } + + private calculateToggleText( + toggleText: string | undefined, + toggleTextExpanded: string | undefined, + toggleTextCollapsed: string | undefined, + propOrStateIsExpanded: boolean | undefined, + ) { + if (propOrStateIsExpanded && toggleTextExpanded !== '') { + return toggleTextExpanded + } + if (!propOrStateIsExpanded && toggleTextCollapsed !== '') { + return toggleTextCollapsed + } + return toggleText + } + + componentDidMount() { + if (this.props.variant === ExpandableSectionVariant.truncate) { + const expandableContent = this.expandableContentRef.current + if (!expandableContent) { + return + } + this.setState({ previousWidth: expandableContent.offsetWidth }) + this.observer = getResizeObserver(expandableContent, this.handleResize, false) + + if (this.props.truncateMaxLines) { + setLineClamp(this.props.truncateMaxLines, expandableContent) + } + + this.checkToggleVisibility() + } + } + + componentDidUpdate(prevProps: ExpandableSectionProps) { + if ( + this.props.variant === ExpandableSectionVariant.truncate && + this.props.truncateMaxLines && + this.expandableContentRef.current && + (prevProps.truncateMaxLines !== this.props.truncateMaxLines || prevProps.children !== this.props.children) + ) { + const expandableContent = this.expandableContentRef.current + setLineClamp(this.props.truncateMaxLines, expandableContent) + this.checkToggleVisibility() + } + } + + componentWillUnmount() { + if (this.props.variant === ExpandableSectionVariant.truncate) { + this.observer() + } + } + + checkToggleVisibility = () => { + if (this.expandableContentRef?.current) { + const maxLines = this.props.truncateMaxLines || parseInt(lineClamp.value) + const totalLines = + this.expandableContentRef.current.scrollHeight / + parseInt(getComputedStyle(this.expandableContentRef.current).lineHeight) + + this.setState({ + hasToggle: totalLines > maxLines, + }) + } + } + + resize = () => { + if (!this.expandableContentRef.current) { + return + } + const { offsetWidth } = this.expandableContentRef.current + if (this.state.previousWidth !== offsetWidth) { + this.setState({ previousWidth: offsetWidth }) + this.checkToggleVisibility() + } + } + handleResize = debounce(this.resize, 250) + + render() { + const { + onToggle: onToggleProp, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isActive, + className, + toggleText, + toggleTextExpanded, + toggleTextCollapsed, + toggleContent, + children, + isExpanded, + isDetached, + displaySize, + isWidthLimited, + isIndented, + contentId, + toggleId, + variant, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + truncateMaxLines, + ...props + } = this.props + + if (isDetached && !toggleId) { + /* eslint-disable no-console */ + console.warn( + 'ExpandableSection: The toggleId value must be passed in and must match the toggleId of the ExpandableSectionToggle.', + ) + } + + let onToggle = onToggleProp + let propOrStateIsExpanded = isExpanded + const uniqueContentId = contentId || getUniqueId('expandable-section-content') + const uniqueToggleId = toggleId || getUniqueId('expandable-section-toggle') + + // uncontrolled + if (isExpanded === undefined) { + propOrStateIsExpanded = this.state.isExpanded + onToggle = (event, isOpen) => { + this.setState({ isExpanded: isOpen }, () => onToggleProp?.(event, this.state.isExpanded)) + } + } + + const computedToggleText = this.calculateToggleText( + toggleText, + toggleTextExpanded, + toggleTextCollapsed, + propOrStateIsExpanded, + ) + + const expandableToggle = !isDetached && ( + + ) + + return ( +
+ {variant === ExpandableSectionVariant.default && expandableToggle} + + {variant === ExpandableSectionVariant.truncate && this.state.hasToggle && expandableToggle} +
+ ) + } +} + +export const ExpandableText: React.FunctionComponent<{ children: React.ReactNode }> = ({ children }) => { + if (isString(children)) { + return ( + { + ;(e.target as HTMLElement)?.classList.contains('pf-v5-c-expandable-section__toggle-text') && + e.stopPropagation() + }} + truncateMaxLines={3} + variant='truncate' + toggleTextExpanded='Show less' + toggleTextCollapsed='Show more' + > + {children} + + ) + } + return children +} diff --git a/packages/hawtio/src/ui/util/FilteredTable.tsx b/packages/hawtio/src/ui/util/FilteredTable.tsx new file mode 100644 index 000000000..6e4fbdea1 --- /dev/null +++ b/packages/hawtio/src/ui/util/FilteredTable.tsx @@ -0,0 +1,328 @@ +import { + Bullseye, + Button, + Dropdown, + DropdownItem, + DropdownList, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + FormGroup, + MenuToggle, + MenuToggleElement, + Pagination, + Panel, + PanelHeader, + PanelMain, + PanelMainBody, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core' +import { Table, Thead, Tr, Th, Tbody, Td, ThProps } from '@patternfly/react-table' +import React, { useEffect, useMemo, useState } from 'react' + +import { SearchIcon } from '@patternfly/react-icons' +import { ExpandableText } from './ExpandableText' +import { isNumber, objectSorter } from '@hawtiosrc/util/objects' + +interface Props { + extraToolbar?: React.ReactNode + tableColumns: { name?: string; key?: keyof T; renderer?: (value: T) => React.ReactNode }[] + rows: T[] + searchCategories: { name: string; key: keyof T }[] + onClick?: (value: T) => void + highlightSearch?: boolean +} + +function numberOrString(value: unknown): string | number { + if (isNumber(value)) { + return Number(value) + } else { + return String(value) + } +} + +export function FilteredTable({ + extraToolbar, + tableColumns, + rows, + searchCategories, + onClick, + highlightSearch = false, +}: Props) { + const defaultSearchCategory = searchCategories[0]?.key + + const [filters, setFilters] = useState<{ key?: keyof T; value: string; name: string }[]>([]) + const [searchTerm, setSearchTerm] = useState<{ key?: keyof T; value: string }>({ + key: defaultSearchCategory, + value: '', + }) + const [filteredRows, setFilteredRows] = useState([]) + + const [attributeMenuItem, setAttributeMenuItem] = useState<{ name: string; key: keyof T } | null>(() => { + if (searchCategories[0]) { + return { key: searchCategories[0].key, name: searchCategories[0].name } + } + return null + }) + + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(20) + + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + + const [sortIndex, setSortIndex] = useState(-1) + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + + useEffect(() => { + let filtered: T[] = [...rows] + + //add current searchTerm and filter + ;[...filters, searchTerm].forEach(filter => { + const key = filter.key + if (!key) { + return + } + const searchTerm = filter.value + filtered = filtered.filter(value => { + return String(value[key]).toLowerCase().includes(searchTerm.toLowerCase()) + }) + }) + + //If user is filtering - refreshing the threds themselves would reset the page count + if (filtered.length != rows.length) { + setPage(1) + } + setFilteredRows([...filtered]) + }, [rows, searchTerm, attributeMenuItem, filters]) + + const getValuesForDisplay = (item: T): (string | number)[] => { + return tableColumns.map(({ key }) => numberOrString(key && item[key])) + } + + const sortedFilteredRows = useMemo(() => { + let sortedThreads = filteredRows + if (sortIndex >= 0) { + sortedThreads = filteredRows.sort((a, b) => { + const aValue = getValuesForDisplay(a)[sortIndex] + const bValue = getValuesForDisplay(b)[sortIndex] + return objectSorter(aValue, bValue, sortDirection === 'desc') + }) + } + return sortedThreads + }, [filteredRows, sortDirection, sortIndex]) + + const clearFilters = () => { + setFilters([]) + setSearchTerm({ + key: searchTerm.key, + value: '', + }) + } + + const addToFilters = () => { + if (searchTerm.value !== '' && attributeMenuItem) { + setFilters([...filters, { ...searchTerm, name: attributeMenuItem.name }]) + setSearchTerm({ key: searchTerm.key, value: '' }) + } + } + + const getRowPage = () => { + const start = (page - 1) * perPage + const end = start + perPage + return filteredRows.slice(start, end) + } + + const propsPagination = ( + setPage(value)} + onPerPageSelect={(_evt, value) => { + setPerPage(value) + setPage(1) + }} + variant='top' + /> + ) + + const filterDropdownItems = searchCategories.map(category => ( + setAttributeMenuItem(category)}> + {category.name} + + )) + + const getFilterChips = () => { + let chips = filters.map(({ name, value }) => `${name}: ${value}`) + if (searchTerm.value) { + chips.push(`${attributeMenuItem?.name}: ${searchTerm.value}`) + } + + return chips + } + + const onDeleteFilter = (filter: string) => { + if (filter === `${attributeMenuItem}: ${searchTerm.value}`) { + setSearchTerm({ + key: attributeMenuItem?.key, + value: '', + }) + } else { + const newFilters = filters.filter(f => `${f.name}: ${f.value}` !== filter) + setFilters(newFilters) + } + } + + const tableToolBar = ( + + + + { + setIsDropdownOpen(false) + addToFilters() + }} + onOpenChange={setIsDropdownOpen} + defaultValue='Name' + toggle={(toggleRef: React.Ref) => ( + setIsDropdownOpen(!isDropdownOpen)} + > + {attributeMenuItem?.name} + + )} + isOpen={isDropdownOpen} + > + {filterDropdownItems} + + onDeleteFilter(filter as string)} + deleteChipGroup={clearFilters} + categoryName='Filters' + > + setSearchTerm({ key: attributeMenuItem?.key, value })} + aria-label='Search input' + /> + + + + {extraToolbar} + + {propsPagination} + + + ) + + const getSortParams = (sortColumn: number): ThProps['sort'] | undefined => { + if (tableColumns[sortColumn]?.name && tableColumns[sortColumn]?.key) { + return { + sortBy: { + index: sortIndex, + direction: sortDirection, + defaultDirection: 'asc', // starting sort direction when first sorting a column. Defaults to 'asc' + }, + onSort: (_event, index, direction) => { + setSortIndex(index) + setSortDirection(direction) + }, + columnIndex: sortColumn, + } + } + return undefined + } + + const highlightSearchedText = (text: string, search: string) => { + if (search === '') { + return text + } + + const lowerCaseSearch = search.toLowerCase() + const res = text + .split(new RegExp(`(${search})`, 'gi')) + .map((s, i) => (s.toLowerCase() === lowerCaseSearch ? {s} : s)) + return res + } + + const defaultCellRenderer = (data: (string | number)[], row: number, column: number) => { + let text: React.ReactNode = data[column] + if (highlightSearch) { + text = highlightSearchedText( + String(text), + [...filters, searchTerm] + .filter(f => f.key === tableColumns[column]?.key) + .map(f => f.value) + .join('|'), + ) + } + return {text} + } + + return ( + + {tableToolBar} + + + {sortedFilteredRows.length > 0 && ( + + + + + {tableColumns.map((att, index) => ( + + ))} + + + + {getRowPage().map((item, index) => { + const values = getValuesForDisplay(item) + return ( + onClick?.(item)}> + {tableColumns.map(({ renderer }, column) => ( + + ))} + + ) + })} + +
+ {att.name} +
+ {renderer ? renderer(item) : defaultCellRenderer(values, index, column)} +
+
+ )} + {sortedFilteredRows.length === 0 && ( + + + } /> + No results found. + + + )} +
+
+
+ ) +} diff --git a/packages/hawtio/src/ui/util/index.ts b/packages/hawtio/src/ui/util/index.ts new file mode 100644 index 000000000..59b985d16 --- /dev/null +++ b/packages/hawtio/src/ui/util/index.ts @@ -0,0 +1,2 @@ +export { FilteredTable } from './FilteredTable' +export { ExpandableText } from './ExpandableText'