diff --git a/package.json b/package.json index f66c6e51..ff84e8c2 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@patternfly/react-core": "6.0.0-alpha.28", "@patternfly/react-icons": "6.0.0-alpha.11", "@patternfly/react-styles": "6.0.0-alpha.11", + "@patternfly/react-table": "6.0.0-alpha.28", "@storybook/builder-webpack5": "^7.5.3", "react": "^18", "react-dom": "^18", diff --git a/src/app/Inventory/Inventory.tsx b/src/app/Inventory/Inventory.tsx new file mode 100644 index 00000000..d3e3f37c --- /dev/null +++ b/src/app/Inventory/Inventory.tsx @@ -0,0 +1,706 @@ +import React from 'react'; +import { + SearchInput, + Toolbar, + ToolbarContent, + ToolbarItem, + Menu, + MenuContent, + MenuList, + MenuItem, + MenuToggle, + MenuToggleCheckbox, + Popper, + Pagination, + EmptyState, + EmptyStateHeader, + EmptyStateFooter, + EmptyStateBody, + Button, + Bullseye, + Badge, + ToolbarGroup, + ToolbarFilter, + ToolbarToggleGroup, + EmptyStateActions, + EmptyStateIcon, +} from '@patternfly/react-core'; +import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import FilterIcon from '@patternfly/react-icons/dist/esm/icons/filter-icon'; + +interface Repository { + name: string; + threads: string; + apps: string; + workspaces: string; + status: string; + location: string; +} + +// In real usage, this data would come from some external source like an API via props. +const repositories: Repository[] = [ + { name: 'US-Node 1', threads: '5', apps: '25', workspaces: '5', status: 'Stopped', location: 'Raleigh' }, + { name: 'US-Node 2', threads: '5', apps: '30', workspaces: '2', status: 'Down', location: 'Westford' }, + { name: 'US-Node 3', threads: '13', apps: '35', workspaces: '12', status: 'Degraded', location: 'Boston' }, + { name: 'US-Node 4', threads: '2', apps: '5', workspaces: '18', status: 'Needs Maintenance', location: 'Raleigh' }, + { name: 'US-Node 5', threads: '7', apps: '30', workspaces: '5', status: 'Running', location: 'Boston' }, + { name: 'US-Node 6', threads: '5', apps: '20', workspaces: '15', status: 'Stopped', location: 'Raleigh' }, + { name: 'CZ-Node 1', threads: '12', apps: '48', workspaces: '13', status: 'Down', location: 'Brno' }, + { name: 'CZ-Node 2', threads: '3', apps: '8', workspaces: '20', status: 'Running', location: 'Brno' }, + { name: 'CZ-Remote-Node 1', threads: '1', apps: '15', workspaces: '20', status: 'Down', location: 'Brno' }, + { name: 'Bangalore-Node 1', threads: '1', apps: '20', workspaces: '20', status: 'Running', location: 'Bangalore' }, +]; + +const columnNames = { + name: 'Servers', + threads: 'Threads', + apps: 'Applications', + workspaces: 'Workspaces', + status: 'Status', + location: 'Location', +}; + +export const FilterAttributeSearch: React.FunctionComponent = () => { + // Set up repo filtering + const [searchValue, setSearchValue] = React.useState(''); + const [locationSelections, setLocationSelections] = React.useState([]); + const [statusSelection, setStatusSelection] = React.useState(''); + + const onSearchChange = (value: string) => { + setSearchValue(value); + }; + + const onFilter = (repo: Repository) => { + // Search name with search value + let searchValueInput: RegExp; + try { + searchValueInput = new RegExp(searchValue, 'i'); + } catch (err) { + searchValueInput = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + } + const matchesSearchValue = repo.name.search(searchValueInput) >= 0; + + // Search status with status selection + const matchesStatusValue = repo.status.toLowerCase() === statusSelection.toLowerCase(); + + // Search location with location selections + const matchesLocationValue = locationSelections.includes(repo.location); + + return ( + (searchValue === '' || matchesSearchValue) && + (statusSelection === '' || matchesStatusValue) && + (locationSelections.length === 0 || matchesLocationValue) + ); + }; + const filteredRepos = repositories.filter(onFilter); + + // Set up table row selection + // In this example, selected rows are tracked by the repo names from each row. This could be any unique identifier. + // This is to prevent state from being based on row order index in case we later add sorting. + const isRepoSelectable = (repo: Repository) => repo.name !== 'a'; // Arbitrary logic for this example + const [selectedRepoNames, setSelectedRepoNames] = React.useState([]); + const setRepoSelected = (repo: Repository, isSelecting = true) => + setSelectedRepoNames((prevSelected) => { + const otherSelectedRepoNames = prevSelected.filter((r) => r !== repo.name); + return isSelecting && isRepoSelectable(repo) ? [...otherSelectedRepoNames, repo.name] : otherSelectedRepoNames; + }); + const selectAllRepos = (isSelecting = true) => + setSelectedRepoNames(isSelecting ? filteredRepos.map((r) => r.name) : []); // Selecting all should only select all currently filtered rows + const areAllReposSelected = selectedRepoNames.length === filteredRepos.length && filteredRepos.length > 0; + const areSomeReposSelected = selectedRepoNames.length > 0; + const isRepoSelected = (repo: Repository) => selectedRepoNames.includes(repo.name); + + // To allow shift+click to select/deselect multiple rows + const [recentSelectedRowIndex, setRecentSelectedRowIndex] = React.useState(null); + const [shifting, setShifting] = React.useState(false); + + const onSelectRepo = (repo: Repository, rowIndex: number, isSelecting: boolean) => { + // If the user is shift + selecting the checkboxes, then all intermediate checkboxes should be selected + if (shifting && recentSelectedRowIndex !== null) { + const numberSelected = rowIndex - recentSelectedRowIndex; + const intermediateIndexes = + numberSelected > 0 + ? Array.from(new Array(numberSelected + 1), (_x, i) => i + recentSelectedRowIndex) + : Array.from(new Array(Math.abs(numberSelected) + 1), (_x, i) => i + rowIndex); + intermediateIndexes.forEach((index) => setRepoSelected(repositories[index], isSelecting)); + } else { + setRepoSelected(repo, isSelecting); + } + setRecentSelectedRowIndex(rowIndex); + }; + + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShifting(true); + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShifting(false); + } + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + }; + }, []); + + // Set up bulk selection menu + const bulkSelectMenuRef = React.useRef(null); + const bulkSelectToggleRef = React.useRef(null); + const bulkSelectContainerRef = React.useRef(null); + + const [isBulkSelectOpen, setIsBulkSelectOpen] = React.useState(false); + + const handleBulkSelectClickOutside = (event: MouseEvent) => { + if (isBulkSelectOpen && !bulkSelectMenuRef.current?.contains(event.target as Node)) { + setIsBulkSelectOpen(false); + } + }; + + const handleBulkSelectMenuKeys = (event: KeyboardEvent) => { + if (!isBulkSelectOpen) { + return; + } + if ( + bulkSelectMenuRef.current?.contains(event.target as Node) || + bulkSelectToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsBulkSelectOpen(!isBulkSelectOpen); + bulkSelectToggleRef.current?.querySelector('button').focus(); + } + } + }; + + React.useEffect(() => { + window.addEventListener('keydown', handleBulkSelectMenuKeys); + window.addEventListener('click', handleBulkSelectClickOutside); + return () => { + window.removeEventListener('keydown', handleBulkSelectMenuKeys); + window.removeEventListener('click', handleBulkSelectClickOutside); + }; + }, [isBulkSelectOpen, bulkSelectMenuRef]); + + const onBulkSelectToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (bulkSelectMenuRef.current) { + const firstElement = bulkSelectMenuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsBulkSelectOpen(!isBulkSelectOpen); + }; + + let menuToggleCheckmark: boolean | null = false; + if (areAllReposSelected) { + menuToggleCheckmark = true; + } else if (areSomeReposSelected) { + menuToggleCheckmark = null; + } + + const bulkSelectToggle = ( + selectAllRepos(checked)} + />, + ], + }} + aria-label="Full table selection checkbox" + /> + ); + + const bulkSelectMenu = ( + { + selectAllRepos(itemId === 1 || itemId === 2); + setIsBulkSelectOpen(!isBulkSelectOpen); + bulkSelectToggleRef.current?.querySelector('button').focus(); + }} + > + + + Select none (0 items) + Select page ({repositories.length} items) + Select all ({repositories.length} items) + + + + ); + + const toolbarBulkSelect = ( +
+ +
+ ); + + // Set up name search input + const searchInput = ( + onSearchChange(value)} + onClear={() => onSearchChange('')} + /> + ); + + // Set up status single select + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const statusContainerRef = React.useRef(null); + + const handleStatusMenuKeys = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }; + + const handleStatusClickOutside = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + + React.useEffect(() => { + window.addEventListener('keydown', handleStatusMenuKeys); + window.addEventListener('click', handleStatusClickOutside); + return () => { + window.removeEventListener('keydown', handleStatusMenuKeys); + window.removeEventListener('click', handleStatusClickOutside); + }; + }, [isStatusMenuOpen, statusMenuRef]); + + const onStatusToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (statusMenuRef.current) { + const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsStatusMenuOpen(!isStatusMenuOpen); + }; + + function onStatusSelect(event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + setStatusSelection(itemId.toString()); + setIsStatusMenuOpen(!isStatusMenuOpen); + } + + const statusToggle = ( + + Filter by status + + ); + + const statusMenu = ( + + + + Degraded + Down + Needs maintenance + Running + Stopped + + + + ); + + const statusSelect = ( +
+ +
+ ); + + // Set up location checkbox select + const [isLocationMenuOpen, setIsLocationMenuOpen] = React.useState(false); + const locationToggleRef = React.useRef(null); + const locationMenuRef = React.useRef(null); + const locationContainerRef = React.useRef(null); + + const handleLocationMenuKeys = (event: KeyboardEvent) => { + if (isLocationMenuOpen && locationMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsLocationMenuOpen(!isLocationMenuOpen); + locationToggleRef.current?.focus(); + } + } + }; + + const handleLocationClickOutside = (event: MouseEvent) => { + if (isLocationMenuOpen && !locationMenuRef.current?.contains(event.target as Node)) { + setIsLocationMenuOpen(false); + } + }; + + React.useEffect(() => { + window.addEventListener('keydown', handleLocationMenuKeys); + window.addEventListener('click', handleLocationClickOutside); + return () => { + window.removeEventListener('keydown', handleLocationMenuKeys); + window.removeEventListener('click', handleLocationClickOutside); + }; + }, [isLocationMenuOpen, locationMenuRef]); + + const onLocationMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (locationMenuRef.current) { + const firstElement = locationMenuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsLocationMenuOpen(!isLocationMenuOpen); + }; + + function onLocationMenuSelect(event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const itemStr = itemId.toString(); + + setLocationSelections( + locationSelections.includes(itemStr) + ? locationSelections.filter((selection) => selection !== itemStr) + : [itemStr, ...locationSelections], + ); + } + + const locationToggle = ( + 0 && { badge: {locationSelections.length} })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by location + + ); + + const locationMenu = ( + + + + + Bangalore + + + Boston + + + Brno + + + Raleigh + + + Westford + + + + + ); + + const locationSelect = ( +
+ +
+ ); + + // Set up attribute selector + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState<'Servers' | 'Status' | 'Location'>('Servers'); + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const attributeContainerRef = React.useRef(null); + + const handleAttribueMenuKeys = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; + + const handleAttributeClickOutside = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + + React.useEffect(() => { + window.addEventListener('keydown', handleAttribueMenuKeys); + window.addEventListener('click', handleAttributeClickOutside); + return () => { + window.removeEventListener('keydown', handleAttribueMenuKeys); + window.removeEventListener('click', handleAttributeClickOutside); + }; + }, [isAttributeMenuOpen, attributeMenuRef]); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const attributeToggle = ( + } + > + {activeAttributeMenu} + + ); + const attributeMenu = ( + // eslint-disable-next-line no-console + { + setActiveAttributeMenu(itemId?.toString() as 'Servers' | 'Status' | 'Location'); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Servers + Status + Location + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + // Set up pagination and toolbar + const toolbarPagination = ( + + ); + + const toolbar = ( + { + setSearchValue(''); + setStatusSelection(''); + setLocationSelections([]); + }} + > + + {toolbarBulkSelect} + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteChipGroup={() => setSearchValue('')} + categoryName="Name" + showToolbarItem={activeAttributeMenu === 'Servers'} + > + {searchInput} + + setStatusSelection('')} + deleteChipGroup={() => setStatusSelection('')} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + onLocationMenuSelect(undefined, chip as string)} + deleteChipGroup={() => setLocationSelections([])} + categoryName="Location" + showToolbarItem={activeAttributeMenu === 'Location'} + > + {locationSelect} + + + + {toolbarPagination} + + + ); + + const emptyState = ( + + } /> + No results match the filter criteria. Clear all filters and try again. + + + + + + + ); + + return ( + + {toolbar} + + + + + + + + + + + + + {filteredRepos.length > 0 && + filteredRepos.map((repo, rowIndex) => ( + + + + + + + + + ))} + {filteredRepos.length === 0 && ( + + + + )} + +
+ {columnNames.name}{columnNames.threads}{columnNames.apps}{columnNames.workspaces}{columnNames.status}{columnNames.location}
onSelectRepo(repo, rowIndex, isSelecting), + isSelected: isRepoSelected(repo), + isDisabled: !isRepoSelectable(repo), + }} + /> + + {repo.name} + + {repo.threads} + + {repo.apps} + + {repo.workspaces} + + {repo.status} + + {repo.location} +
+ {emptyState} +
+
+ ); +}; diff --git a/src/app/Settings/General/GeneralSettings.tsx b/src/app/Settings/General/GeneralSettings.tsx deleted file mode 100644 index f70851f0..00000000 --- a/src/app/Settings/General/GeneralSettings.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import { PageSection, Title } from '@patternfly/react-core'; - -const GeneralSettings: React.FunctionComponent = () => ( - - - General Settings Page Title - - -); - -export { GeneralSettings }; diff --git a/src/app/Settings/Profile/ProfileSettings.tsx b/src/app/Settings/Profile/ProfileSettings.tsx deleted file mode 100644 index c5552441..00000000 --- a/src/app/Settings/Profile/ProfileSettings.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import { PageSection, Title } from '@patternfly/react-core'; - -const ProfileSettings: React.FunctionComponent = () => ( - - - Profile Settings Page Title - - -); - -export { ProfileSettings }; diff --git a/src/app/routes.tsx b/src/app/routes.tsx index 4acb14ef..7953f20b 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -2,8 +2,7 @@ import * as React from 'react'; import { Route, RouteComponentProps, Switch, useLocation } from 'react-router-dom'; import { Dashboard } from '@app/Dashboard/Dashboard'; import { Support } from '@app/Support/Support'; -import { GeneralSettings } from '@app/Settings/General/GeneralSettings'; -import { ProfileSettings } from '@app/Settings/Profile/ProfileSettings'; +import { FilterAttributeSearch as Inventory } from '@app/Inventory/Inventory'; import { NotFound } from '@app/NotFound/NotFound'; import { useDocumentTitle } from '@app/utils/useDocumentTitle'; @@ -42,23 +41,11 @@ const routes: AppRouteConfig[] = [ title: 'PatternFly Seed | Support Page', }, { - label: 'Settings', - routes: [ - { - component: GeneralSettings, - exact: true, - label: 'General', - path: '/settings/general', - title: 'PatternFly Seed | General Settings', - }, - { - component: ProfileSettings, - exact: true, - label: 'Profile', - path: '/settings/profile', - title: 'PatternFly Seed | Profile Settings', - }, - ], + component: Inventory, + exact: true, + label: 'Inventory', + path: '/inventory', + title: 'PatternFly Seed | Inventory', }, ]; @@ -99,7 +86,7 @@ const PageNotFound = ({ title }: { title: string }) => { const flattenedRoutes: IAppRoute[] = routes.reduce( (flattened, route) => [...flattened, ...(route.routes ? route.routes : [route])], - [] as IAppRoute[] + [] as IAppRoute[], ); const AppRoutes = (): React.ReactElement => (