Skip to content

Commit

Permalink
improves SelectWithModal to support async fetch of items
Browse files Browse the repository at this point in the history
  • Loading branch information
josemigallas committed Sep 17, 2021
1 parent aa0376b commit 98f2853
Show file tree
Hide file tree
Showing 18 changed files with 882 additions and 158 deletions.
126 changes: 126 additions & 0 deletions app/javascript/src/Common/components/FancySelect.jsx
@@ -0,0 +1,126 @@
// @flow

import * as React from 'react'

import {
FormGroup,
Select,
SelectVariant
} from '@patternfly/react-core'
import {
handleOnFilter,
toSelectOption,
toSelectOptionObject,
SelectOptionObject
} from 'utilities'

import type { Record } from 'utilities'

import './FancySelect.scss'

type Props<T: Record> = {
item: T | null,
items: T[],
onSelect: (T | null) => void,
label: string,
id: string,
header: string,
isDisabled?: boolean,
isValid?: boolean,
name?: string,
helperText?: React.Node,
helperTextInvalid?: string,
placeholderText?: string,
footer?: {
label: string,
onClick: () => void
}
}

const emptyItem = { id: -1, name: 'No results found', disabled: true, privateEndpoint: '' }
const FOOTER_ID = 'footer_id'

const FancySelect = <T: Record>({
item,
items,
onSelect,
label,
id,
header,
isDisabled,
isValid,
name,
helperText,
helperTextInvalid,
placeholderText,
footer
}: Props<T>): React.Node => {
const [expanded, setExpanded] = React.useState(false)

const headerItem = { id: 'header', name: header, disabled: true, className: 'pf-c-select__menu-item--group-name' }
// TODO: Remove after upgrading @patternfly/react-core, see https://www.patternfly.org/v4/components/select#view-more
const footerItem = footer && { id: FOOTER_ID, name: footer.label, className: 'pf-c-select__menu-item--sticky-footer' }

const handleOnSelect = (_e, option: SelectOptionObject) => {
setExpanded(false)

if (option.id === FOOTER_ID) {
// $FlowIgnore[incompatible-use] safe to assume onClick is defined at this point
footer.onClick()
} else {
const selectedBackend = items.find(b => String(b.id) === option.id)

if (selectedBackend) {
onSelect(selectedBackend)
}
}
}

const getSelectOptionsForItems = (items: Array<T>) => {
const selectItems = [headerItem]

if (items.length === 0) {
selectItems.push(emptyItem)
} else {
selectItems.push(...items.map(i => ({ ...i, className: 'pf-c-select__menu-item-description' })))
}

if (footerItem) {
selectItems.push(footerItem)
}

return selectItems.map(toSelectOption)
}

return (
<FormGroup
isRequired
label={label}
fieldId={id}
helperText={helperText}
helperTextInvalid={helperTextInvalid}
isValid={isValid}
>
{name && item && <input type="hidden" name={name} value={item.id} />}
<Select
variant={SelectVariant.typeahead}
selections={item && toSelectOptionObject(item)}
onToggle={() => setExpanded(!expanded)}
onSelect={handleOnSelect}
isExpanded={expanded}
isDisabled={isDisabled}
onClear={() => onSelect(null)}
aria-labelledby={id}
className={footer ? 'pf-c-select__menu--with-fixed-link' : undefined}
isGrouped
// $FlowIgnore[incompatible-call] yes it is
onFilter={handleOnFilter(items, getSelectOptionsForItems)}
placeholderText={placeholderText}
>
{getSelectOptionsForItems(items)}
</Select>
</FormGroup>
)
}

export { FancySelect }
18 changes: 18 additions & 0 deletions app/javascript/src/Common/components/FancySelect.scss
@@ -0,0 +1,18 @@
// For a select box with fixed link at the bottom, select the last li to be that sticky link
.pf-c-select__menu--with-fixed-link li:last-of-type {
background-color: var(--pf-global--BackgroundColor--100);
bottom: calc(-1 * var(--pf-global--spacer--sm));
padding-bottom: var(--pf-global--spacer--sm);
padding-top: var(--pf-global--spacer--xs);
position: sticky;

// Make the sticky link look like a link
.pf-c-select__menu-item {
color: var(--pf-global--link--Color);

&:hover {
background-color: var(--pf-global--BackgroundColor--100);
color: var(--pf-global--link--Color--hover);
}
}
}
38 changes: 38 additions & 0 deletions app/javascript/src/Common/components/NoMatchFound.jsx
@@ -0,0 +1,38 @@
// @flow

import * as React from 'react'

import {
Button,
Title,
EmptyState,
EmptyStatePrimary,
EmptyStateIcon,
EmptyStateBody
} from '@patternfly/react-core'
import { SearchIcon } from '@patternfly/react-icons'

import './NoMatchFound.scss'

type Props = {
onClearFiltersClick?: () => void
}

const NoMatchFound = ({ onClearFiltersClick }: Props): React.Node => (
<EmptyState>
<EmptyStateIcon icon={SearchIcon} />
<Title size="lg" headingLevel="h4">
No results found
</Title>
<EmptyStateBody>
No results match the filter criteria. Clear all filters to show results.
</EmptyStateBody>
{onClearFiltersClick && (
<EmptyStatePrimary>
<Button variant="link" onClick={onClearFiltersClick}>Clear all filters</Button>
</EmptyStatePrimary>
)}
</EmptyState>
)

export { NoMatchFound }
1 change: 1 addition & 0 deletions app/javascript/src/Common/components/NoMatchFound.scss
@@ -0,0 +1 @@
@import '~@patternfly/patternfly/components/Title/title.css';
197 changes: 197 additions & 0 deletions app/javascript/src/Common/components/PaginatedTableModal.jsx
@@ -0,0 +1,197 @@
// @flow

import * as React from 'react'
import { useState, useEffect, useRef } from 'react'

import {
Button,
Modal,
InputGroup,
TextInput,
Pagination,
Spinner,
Toolbar,
ToolbarItem
} from '@patternfly/react-core'
import {
Table,
TableHeader,
TableBody,
SortByDirection
} from '@patternfly/react-table'
import { SearchIcon } from '@patternfly/react-icons'
import { NoMatchFound } from 'Common'

import type { Record } from 'utilities'

import './TableModal.scss'

type Props<T: Record> = {
title: string,
selectedItem: T | null,
pageItems?: T[],
itemsCount: number,
onSelect: (T | null) => void,
onClose: () => void,
cells: Array<{ title: string, propName: string, transforms?: any }>,
isOpen?: boolean,
isLoading?: boolean,
page: number,
setPage: (number) => void,
perPage?: number,
onSearch?: (term: string) => void,
sortBy: { index: number, direction: $Keys<typeof SortByDirection> }
}

const PER_PAGE_DEFAULT = 5

const PaginatedTableModal = <T: Record>({
title,
isOpen,
isLoading = false,
selectedItem,
pageItems = [],
itemsCount,
onSelect,
onClose,
cells,
perPage = PER_PAGE_DEFAULT,
page,
setPage,
onSearch,
sortBy
}: Props<T>): React.Node => {
const [selected, setSelected] = useState<T | null>(selectedItem)
const searchInputRef = useRef<HTMLInputElement | null>(null)

// FIXME: this should really be done by useSearchInputEffect. The ref won't work though.
useEffect(() => {
if (searchInputRef.current && onSearch) {
const { current } = searchInputRef

current.addEventListener('input', ({ inputType }: InputEvent) => {
if (!inputType) onSearch('')
})

current.addEventListener('keydown', ({ key }: KeyboardEvent) => {
if (key === 'Enter' && searchInputRef.current) onSearch(searchInputRef.current.value)
})
}
}, [searchInputRef])

useEffect(() => {
// Need to use effect since selected won't be re-declared on param item selectedItem change
setSelected(selectedItem)
}, [selectedItem])

const handleOnSelect = (_e, _i, rowId: number) => {
setSelected(pageItems[rowId])
}

const handleOnClickSearch = () => {
if (searchInputRef.current) {
// $FlowIgnore[not-a-function] not clickable if onSearch undefined
onSearch(searchInputRef.current.value)
}
}

const pagination = (
<Pagination
perPage={perPage}
itemCount={itemsCount}
page={page}
onSetPage={(_e, page) => setPage(page)}
widgetId="pagination-options-menu-top"
isDisabled={isLoading}
/>
)

const rows = pageItems.map((i) => ({
selected: i.id === selected?.id,
cells: cells.map(({ propName }) => i[propName])
}))

const onAccept = () => {
onSelect(selected)
}

const onCancel = () => {
setSelected(selectedItem)
onClose()
}

const actions = [
<Button
key="Select"
variant="primary"
isDisabled={selected === null || isLoading}
onClick={onAccept}
data-testid="select"
>
Select
</Button>,
<Button
key="Cancel"
variant="secondary"
isDisabled={isLoading}
onClick={onCancel}
data-testid="cancel"
>
Cancel
</Button>
]

return (
<Modal
isLarge
title={title}
isOpen={isOpen}
onClose={onCancel}
isFooterLeftAligned={true}
actions={actions}
>
{/* Toolbar is a component in the css, but a layout in react, so the class names are mismatched (pf-c-toolbar vs pf-l-toolbar) Styling doesn't work, but if you change it to pf-c in the inspector, it works */}
<Toolbar className="pf-c-toolbar pf-u-justify-content-space-between">
<ToolbarItem>
<InputGroup>
<TextInput
type="search"
aria-label="search for an item"
ref={searchInputRef}
isDisabled={isLoading || !onSearch}
/>
<Button
variant="control"
aria-label="search button for search input"
onClick={handleOnClickSearch}
data-testid="search"
isDisabled={isLoading || !onSearch}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
<ToolbarItem>
{pagination}
</ToolbarItem>
</Toolbar>
{isLoading ? <Spinner size='xl' /> : rows.length === 0 ? <NoMatchFound /> : (
<Table
aria-label={title}
sortBy={sortBy}
onSort={() => {}}
onSelect={handleOnSelect}
cells={cells}
rows={rows}
selectVariant='radio'
>
<TableHeader />
<TableBody />
</Table>
)}
{pagination}
</Modal>
)
}

export { PaginatedTableModal }

0 comments on commit 98f2853

Please sign in to comment.