Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

urql: Convert UserList to use urql #3823

Merged
merged 11 commits into from
Apr 26, 2024
5 changes: 3 additions & 2 deletions web/src/app/lists/FlatListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'
import ListItemText from '@mui/material/ListItemText'
import makeStyles from '@mui/styles/makeStyles'
import AppLink from '../util/AppLink'
import AppLink, { AppLinkListItem } from '../util/AppLink'
import { FlatListItemOptions } from './FlatList'

const useStyles = makeStyles(() => ({
Expand Down Expand Up @@ -63,7 +63,8 @@ export default function FlatListItem(props: FlatListItemProps): JSX.Element {
let linkProps = {}
if (url) {
linkProps = {
component: AppLink,
// MUI renders differently based on if secondaryAction is present
component: secondaryAction ? AppLink : AppLinkListItem,
to: url,
button: true,
}
Expand Down
157 changes: 157 additions & 0 deletions web/src/app/lists/ListPageControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { useEffect } from 'react'
import {
Button,
Card,
CircularProgress,
Grid,
IconButton,
Theme,
} from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Add, ChevronLeft, ChevronRight } from '@mui/icons-material'
import CreateFAB from './CreateFAB'
import { useIsWidthDown } from '../util/useWidth'
import { usePages } from '../util/pagination'
import { useURLKey } from '../actions'

const useStyles = makeStyles((theme: Theme) => ({
progress: {
color: theme.palette.secondary.main,
position: 'absolute',
},
controls: {
[theme.breakpoints.down('sm')]: {
'&:not(:first-child)': {
marginBottom: '4.5em',
paddingBottom: '1em',
},
},
},
}))
type ListPageControlsBaseProps = {
nextCursor: string | null | undefined
onCursorChange: (cursor: string) => void

loading?: boolean

slots: {
list: React.ReactNode
search?: React.ReactNode
}

// ignored unless onCreateClick is provided
createLabel?: string
}

type ListPageControlsCreatableProps = ListPageControlsBaseProps & {
createLabel: string
onCreateClick: () => void
}

export type ListPageControlsProps =
| ListPageControlsBaseProps
| ListPageControlsCreatableProps

function canCreate(
props: ListPageControlsProps,
): props is ListPageControlsCreatableProps {
return 'createLabel' in props
}

export default function ListPageControls(
props: ListPageControlsProps,
): React.ReactNode {
const classes = useStyles()
const showCreate = canCreate(props)
const isMobile = useIsWidthDown('md')

const [back, next, reset] = usePages(props.nextCursor)
const urlKey = useURLKey()
// reset pageNumber on page reload
useEffect(() => {
props.onCursorChange(reset())
}, [urlKey])

return (
<Grid container spacing={2}>
<Grid
container
item
xs={12}
spacing={2}
justifyContent='flex-start'
alignItems='center'
>
{props.slots.search && <Grid item>{props.slots.search}</Grid>}

{showCreate && !isMobile && (
<Grid item sx={{ ml: 'auto' }}>
<Button
variant='contained'
startIcon={<Add />}
onClick={props.onCreateClick}
>
Create {props.createLabel}
</Button>
</Grid>
)}
</Grid>

<Grid item xs={12}>
<Card>{props.slots.list}</Card>
</Grid>

<Grid
item // item within main render grid
xs={12}
container // container for control items
spacing={1}
justifyContent='flex-end'
alignItems='center'
className={classes.controls}
>
<Grid item>
<IconButton
title='back page'
data-cy='back-button'
disabled={!back}
onClick={() => {
if (back) props.onCursorChange(back())
window.scrollTo(0, 0)
}}
>
<ChevronLeft />
</IconButton>
</Grid>
<Grid item>
<IconButton
title='next page'
data-cy='next-button'
disabled={!next || props.loading}
onClick={() => {
if (next) props.onCursorChange(next())
window.scrollTo(0, 0)
}}
>
{props.loading && (
<CircularProgress
color='secondary'
size={24}
className={classes.progress}
/>
)}
<ChevronRight />
</IconButton>
</Grid>
</Grid>
{showCreate && isMobile && (
<React.Fragment>
<CreateFAB
onClick={props.onCreateClick}
title={`Create ${props.createLabel}`}
/>
</React.Fragment>
)}
</Grid>
)
}
13 changes: 2 additions & 11 deletions web/src/app/lists/PaginatedList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode, ReactElement, forwardRef } from 'react'
import React, { ReactNode, ReactElement } from 'react'
import Avatar from '@mui/material/Avatar'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
Expand All @@ -13,7 +13,7 @@ import { FavoriteIcon } from '../util/SetFavoriteButton'
import { ITEMS_PER_PAGE } from '../config'
import Spinner from '../loading/components/Spinner'
import { CheckboxItemsProps } from './ControlledPaginatedList'
import AppLink, { AppLinkProps } from '../util/AppLink'
import { AppLinkListItem } from '../util/AppLink'
import { debug } from '../util/debug'
import useStatusColors from '../theme/useStatusColors'

Expand Down Expand Up @@ -128,15 +128,6 @@ export function PaginatedList(props: PaginatedListProps): JSX.Element {
}
}

const AppLinkListItem = forwardRef<HTMLAnchorElement, AppLinkProps>(
(props, ref) => (
<li>
<AppLink ref={ref} {...props} />
</li>
),
)
AppLinkListItem.displayName = 'AppLinkListItem'

// must be explicitly set when using, in accordance with TS definitions
const urlProps = item.url && {
component: AppLinkListItem,
Expand Down
93 changes: 66 additions & 27 deletions web/src/app/users/UserList.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import React from 'react'
import { gql } from 'urql'
import React, { Suspense, useState } from 'react'
import { gql, useQuery } from 'urql'
import { UserAvatar } from '../util/avatars'
import QueryList from '../lists/QueryList'
import UserPhoneNumberFilterContainer from './UserPhoneNumberFilterContainer'
import UserCreateDialog from './UserCreateDialog'
import { useSessionInfo } from '../util/RequireConfig'
import ListPageControls from '../lists/ListPageControls'
import Search from '../util/Search'
import FlatList from '../lists/FlatList'
import { UserConnection } from '../../schema'
import { useURLParam } from '../actions'
import { FavoriteIcon } from '../util/SetFavoriteButton'

const query = gql`
query usersQuery($input: UserSearchOptions) {
data: users(input: $input) {
users(input: $input) {
nodes {
id
name
Expand All @@ -23,34 +28,68 @@ const query = gql`
}
`

const context = { suspense: false }

function UserList(): JSX.Element {
const { isAdmin, ready } = useSessionInfo()
const { isAdmin } = useSessionInfo()
const [create, setCreate] = useState(false)
const [search] = useURLParam<string>('search', '')
const [cursor, setCursor] = useState('')

const inputVars = {
favoritesFirst: true,
search,
CMValue: '',
after: cursor,
}
if (search.startsWith('phone=')) {
inputVars.CMValue = search.replace(/^phone=/, '')
inputVars.search = ''
}

const [q] = useQuery<{ users: UserConnection }>({
query,
variables: { input: inputVars },
context,
})
const nextCursor = q.data?.users.pageInfo.hasNextPage
? q.data?.users.pageInfo.endCursor
: ''
// cache the next page
useQuery({
query,
variables: { input: { ...inputVars, after: nextCursor } },
context,
pause: !nextCursor,
})

return (
<React.Fragment>
<QueryList
query={query}
variables={{ input: { favoritesFirst: true } }}
mapDataNode={(n) => ({
title: n.name,
subText: n.email,
url: n.id,
isFavorite: n.isFavorite,
icon: <UserAvatar userID={n.id} />,
})}
mapVariables={(vars) => {
if (vars?.input?.search?.startsWith('phone=')) {
vars.input.CMValue = vars.input.search.replace(/^phone=/, '')
vars.input.search = ''
}
return vars
}}
searchAdornment={<UserPhoneNumberFilterContainer />}
renderCreateDialog={(onClose) => {
return <UserCreateDialog onClose={onClose} />
}}
<Suspense>
{create && <UserCreateDialog onClose={() => setCreate(false)} />}
</Suspense>
<ListPageControls
createLabel='User'
hideCreate={!ready || (ready && !isAdmin)}
nextCursor={nextCursor}
onCursorChange={setCursor}
loading={q.fetching}
onCreateClick={isAdmin ? () => setCreate(true) : undefined}
slots={{
search: <Search endAdornment={<UserPhoneNumberFilterContainer />} />,
list: (
<FlatList
items={
q.data?.users.nodes.map((u) => ({
title: u.name,
subText: u.email,
url: u.id,
secondaryAction: u.isFavorite ? <FavoriteIcon /> : undefined,
icon: <UserAvatar userID={u.id} />,
})) || []
}
/>
),
}}
/>
</React.Fragment>
)
Expand Down
9 changes: 9 additions & 0 deletions web/src/app/util/AppLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,12 @@ const AppLink: ForwardRefRenderFunction<HTMLAnchorElement, AppLinkProps> =
}

export default forwardRef(AppLink)

export const AppLinkListItem = forwardRef<HTMLAnchorElement, AppLinkProps>(
(props, ref) => (
<li>
<AppLink ref={ref} {...props} />
</li>
),
)
AppLinkListItem.displayName = 'AppLinkListItem'
36 changes: 36 additions & 0 deletions web/src/app/util/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useState } from 'react'

/**
* usePages is a custom hook that manages pagination state by tracking the current page cursor
* as well as previous page cursors.
*
* @returns {(() => string) | undefined} A function to go back to the previous page, or undefined if there is no previous page.
* @returns {(() => string) | undefined} A function to go to the next page, or undefined if there is no next page.
* @returns {() => string} A function to reset the page cursor to the first page.
*/
export function usePages(
nextCursor: string | null | undefined,
): [(() => string) | undefined, (() => string) | undefined, () => string] {
const [pageCursors, setPageCursors] = useState([''])

function goBack(): string {
const newCursors = pageCursors.slice(0, -1)
setPageCursors(newCursors)
return newCursors[newCursors.length - 1]
}

function goNext(): string {
if (!nextCursor) return pageCursors[pageCursors.length - 1]
setPageCursors([...pageCursors, nextCursor])
return nextCursor
}

return [
pageCursors.length > 1 ? goBack : undefined,
nextCursor ? goNext : undefined,
() => {
setPageCursors([''])
return ''
},
]
}
4 changes: 2 additions & 2 deletions web/src/cypress/e2e/favorites.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ function check(
createFunc(name2, true)
cy.visit(`/${urlPrefix}?search=${encodeURIComponent(prefix)}`)

cy.get('ul[data-cy=paginated-list] li')
cy.get('main ul > li')
.should('have.length', 2)
.first()
.should('contain', name2)
.find('[data-cy=fav-icon]')
.should('exist')

cy.get('ul[data-cy=paginated-list] li').last().should('contain', name1)
cy.get('main ul > li').last().should('contain', name1)
})
if (getSearchSelectFunc) {
it('should sort favorites-first in a search-select', () => {
Expand Down
Loading
Loading