diff options
Diffstat (limited to 'webui/src/list')
-rw-r--r-- | webui/src/list/BugRow.graphql | 14 | ||||
-rw-r--r-- | webui/src/list/BugRow.tsx (renamed from webui/src/list/BugRow.js) | 49 | ||||
-rw-r--r-- | webui/src/list/Filter.tsx (renamed from webui/src/list/Filter.js) | 103 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.graphql | 7 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.tsx (renamed from webui/src/list/FilterToolbar.js) | 85 | ||||
-rw-r--r-- | webui/src/list/List.tsx (renamed from webui/src/list/List.js) | 5 | ||||
-rw-r--r-- | webui/src/list/ListQuery.graphql | 37 | ||||
-rw-r--r-- | webui/src/list/ListQuery.tsx (renamed from webui/src/list/ListQuery.js) | 181 |
8 files changed, 277 insertions, 204 deletions
diff --git a/webui/src/list/BugRow.graphql b/webui/src/list/BugRow.graphql new file mode 100644 index 00000000..3f9a1ef6 --- /dev/null +++ b/webui/src/list/BugRow.graphql @@ -0,0 +1,14 @@ +#import "../Author.graphql" +#import "../Label.graphql" + +fragment BugRow on Bug { + id + humanId + title + status + createdAt + labels { + ...Label + } + ...authored +} diff --git a/webui/src/list/BugRow.js b/webui/src/list/BugRow.tsx index add5c12f..f94538a7 100644 --- a/webui/src/list/BugRow.js +++ b/webui/src/list/BugRow.tsx @@ -1,36 +1,43 @@ -import { makeStyles } from '@material-ui/styles'; import TableCell from '@material-ui/core/TableCell/TableCell'; import TableRow from '@material-ui/core/TableRow/TableRow'; import Tooltip from '@material-ui/core/Tooltip/Tooltip'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import { makeStyles } from '@material-ui/core/styles'; import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import gql from 'graphql-tag'; +import ErrorOutline from '@material-ui/icons/ErrorOutline'; import React from 'react'; import { Link } from 'react-router-dom'; + import Date from '../Date'; import Label from '../Label'; -import Author from '../Author'; +import { Status } from '../gqlTypes'; -const Open = ({ className }) => ( +import { BugRowFragment } from './BugRow.generated'; + +type OpenClosedProps = { className: string }; +const Open = ({ className }: OpenClosedProps) => ( <Tooltip title="Open"> <ErrorOutline htmlColor="#28a745" className={className} /> </Tooltip> ); -const Closed = ({ className }) => ( +const Closed = ({ className }: OpenClosedProps) => ( <Tooltip title="Closed"> <CheckCircleOutline htmlColor="#cb2431" className={className} /> </Tooltip> ); -const Status = ({ status, className }) => { +type StatusProps = { className: string; status: Status }; +const BugStatus: React.FC<StatusProps> = ({ + status, + className, +}: StatusProps) => { switch (status) { case 'OPEN': return <Open className={className} />; case 'CLOSED': return <Closed className={className} />; default: - return 'unknown status ' + status; + return <p>{'unknown status ' + status}</p>; } }; @@ -57,7 +64,6 @@ const useStyles = makeStyles(theme => ({ fontWeight: 500, }, details: { - ...theme.typography.textSecondary, lineHeight: '1.5rem', color: theme.palette.text.secondary, }, @@ -69,12 +75,16 @@ const useStyles = makeStyles(theme => ({ }, })); -function BugRow({ bug }) { +type Props = { + bug: BugRowFragment; +}; + +function BugRow({ bug }: Props) { const classes = useStyles(); return ( <TableRow hover> <TableCell className={classes.cell}> - <Status status={bug.status} className={classes.status} /> + <BugStatus status={bug.status} className={classes.status} /> <div className={classes.expand}> <Link to={'bug/' + bug.humanId}> <div className={classes.expand}> @@ -99,21 +109,4 @@ function BugRow({ bug }) { ); } -BugRow.fragment = gql` - fragment BugRow on Bug { - id - humanId - title - status - createdAt - labels { - ...Label - } - ...authored - } - - ${Label.fragment} - ${Author.fragment} -`; - export default BugRow; diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.tsx index a6cf3633..30b52de8 100644 --- a/webui/src/list/Filter.js +++ b/webui/src/list/Filter.tsx @@ -1,13 +1,18 @@ -import React, { useState, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { makeStyles } from '@material-ui/styles'; import Menu from '@material-ui/core/Menu'; import MenuItem from '@material-ui/core/MenuItem'; +import { SvgIconProps } from '@material-ui/core/SvgIcon'; +import { makeStyles } from '@material-ui/core/styles'; import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; +import clsx from 'clsx'; +import { LocationDescriptor } from 'history'; +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; -function parse(query) { +export type Query = { [key: string]: Array<string> }; + +function parse(query: string): Query { // TODO: extract the rest of the query? - const params = {}; + const params: Query = {}; // TODO: support escaping without quotes const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; @@ -29,7 +34,7 @@ function parse(query) { return params; } -function quote(value) { +function quote(value: string): string { const hasSingle = value.includes("'"); const hasDouble = value.includes('"'); const hasSpaces = value.includes(' '); @@ -49,19 +54,19 @@ function quote(value) { return `"${value}"`; } -function stringify(params) { - const parts = Object.entries(params).map(([key, values]) => { +function stringify(params: Query): string { + const parts: string[][] = Object.entries(params).map(([key, values]) => { return values.map(value => `${key}:${quote(value)}`); }); - return [].concat(...parts).join(' '); + return new Array<string>().concat(...parts).join(' '); } const useStyles = makeStyles(theme => ({ element: { ...theme.typography.body2, - color: ({ active }) => (active ? '#333' : '#444'), + color: '#444', padding: theme.spacing(0, 1), - fontWeight: ({ active }) => (active ? 600 : 400), + fontWeight: 400, textDecoration: 'none', display: 'flex', background: 'none', @@ -69,21 +74,51 @@ const useStyles = makeStyles(theme => ({ }, itemActive: { fontWeight: 600, + color: '#333', }, icon: { paddingRight: theme.spacing(0.5), }, })); -function Dropdown({ children, dropdown, itemActive, to, ...props }) { +type DropdownTuple = [string, string]; + +type FilterDropdownProps = { + children: React.ReactNode; + dropdown: DropdownTuple[]; + itemActive: (key: string) => boolean; + icon?: React.ComponentType<SvgIconProps>; + to: (key: string) => LocationDescriptor; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; + +function FilterDropdown({ + children, + dropdown, + itemActive, + icon: Icon, + to, + ...props +}: FilterDropdownProps) { const [open, setOpen] = useState(false); - const buttonRef = useRef(); - const classes = useStyles(); + const buttonRef = useRef<HTMLButtonElement>(null); + const classes = useStyles({ active: false }); + + const content = ( + <> + {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />} + <div>{children}</div> + </> + ); return ( <> - <button ref={buttonRef} onClick={() => setOpen(!open)} {...props}> - {children} + <button + ref={buttonRef} + onClick={() => setOpen(!open)} + className={classes.element} + {...props} + > + {content} <ArrowDropDown fontSize="small" /> </button> <Menu @@ -104,7 +139,7 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) { <MenuItem component={Link} to={to(key)} - className={itemActive(key) ? classes.itemActive : null} + className={itemActive(key) ? classes.itemActive : undefined} onClick={() => setOpen(false)} key={key} > @@ -116,8 +151,14 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) { ); } -function Filter({ active, to, children, icon: Icon, dropdown, ...props }) { - const classes = useStyles({ active }); +export type FilterProps = { + active: boolean; + to: LocationDescriptor; + icon?: React.ComponentType<SvgIconProps>; + children: React.ReactNode; +}; +function Filter({ active, to, children, icon: Icon }: FilterProps) { + const classes = useStyles(); const content = ( <> @@ -126,29 +167,23 @@ function Filter({ active, to, children, icon: Icon, dropdown, ...props }) { </> ); - if (dropdown) { + if (to) { return ( - <Dropdown - {...props} + <Link to={to} - dropdown={dropdown} - className={classes.element} + className={clsx(classes.element, active && classes.itemActive)} > {content} - </Dropdown> - ); - } - - if (to) { - return ( - <Link to={to} {...props} className={classes.element}> - {content} </Link> ); } - return <div className={classes.element}>{content}</div>; + return ( + <div className={clsx(classes.element, active && classes.itemActive)}> + {content} + </div> + ); } export default Filter; -export { parse, stringify, quote }; +export { parse, stringify, quote, FilterDropdown, Filter }; diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql new file mode 100644 index 00000000..cd103f44 --- /dev/null +++ b/webui/src/list/FilterToolbar.graphql @@ -0,0 +1,7 @@ +query BugCount($query: String) { + repository { + bugs: allBugs(query: $query) { + totalCount + } + } +} diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.tsx index 4d0b52b1..b95b10bc 100644 --- a/webui/src/list/FilterToolbar.js +++ b/webui/src/list/FilterToolbar.tsx @@ -1,16 +1,20 @@ -import { makeStyles } from '@material-ui/styles'; -import { useQuery } from '@apollo/react-hooks'; -import gql from 'graphql-tag'; -import React from 'react'; +import { pipe } from '@arrows/composition'; import Toolbar from '@material-ui/core/Toolbar'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import { makeStyles } from '@material-ui/core/styles'; import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import Filter, { parse, stringify } from './Filter'; +import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import { LocationDescriptor } from 'history'; +import React from 'react'; -// simple pipe operator -// pipe(o, f, g, h) <=> h(g(f(o))) -// TODO: move this out? -const pipe = (initial, ...funcs) => funcs.reduce((acc, f) => f(acc), initial); +import { + FilterDropdown, + FilterProps, + Filter, + parse, + stringify, + Query, +} from './Filter'; +import { useBugCountQuery } from './FilterToolbar.generated'; const useStyles = makeStyles(theme => ({ toolbar: { @@ -25,27 +29,21 @@ const useStyles = makeStyles(theme => ({ }, })); -const BUG_COUNT_QUERY = gql` - query($query: String) { - defaultRepository { - bugs: allBugs(query: $query) { - totalCount - } - } - } -`; - // This prepends the filter text with a count -function CountingFilter({ query, children, ...props }) { - const { data, loading, error } = useQuery(BUG_COUNT_QUERY, { +type CountingFilterProps = { + query: string; + children: React.ReactNode; +} & FilterProps; +function CountingFilter({ query, children, ...props }: CountingFilterProps) { + const { data, loading, error } = useBugCountQuery({ variables: { query }, }); var prefix; if (loading) prefix = '...'; - else if (error) prefix = '???'; + else if (error || !data?.repository) prefix = '???'; // TODO: better prefixes & error handling - else prefix = data.defaultRepository.bugs.totalCount; + else prefix = data.repository.bugs.totalCount; return ( <Filter {...props}> @@ -54,18 +52,26 @@ function CountingFilter({ query, children, ...props }) { ); } -function FilterToolbar({ query, queryLocation }) { +type Props = { + query: string; + queryLocation: (query: string) => LocationDescriptor; +}; +function FilterToolbar({ query, queryLocation }: Props) { const classes = useStyles(); - const params = parse(query); + const params: Query = parse(query); - const hasKey = key => params[key] && params[key].length > 0; - const hasValue = (key, value) => hasKey(key) && params[key].includes(value); - const loc = params => pipe(params, stringify, queryLocation); - const replaceParam = (key, value) => params => ({ + const hasKey = (key: string): boolean => + params[key] && params[key].length > 0; + const hasValue = (key: string, value: string): boolean => + hasKey(key) && params[key].includes(value); + const loc = pipe(stringify, queryLocation); + const replaceParam = (key: string, value: string) => ( + params: Query + ): Query => ({ ...params, [key]: [value], }); - const clearParam = key => params => ({ + const clearParam = (key: string) => (params: Query): Query => ({ ...params, [key]: [], }); @@ -76,12 +82,11 @@ function FilterToolbar({ query, queryLocation }) { <CountingFilter active={hasValue('status', 'open')} query={pipe( - params, replaceParam('status', 'open'), clearParam('sort'), stringify - )} - to={pipe(params, replaceParam('status', 'open'), loc)} + )(params)} + to={pipe(replaceParam('status', 'open'), loc)(params)} icon={ErrorOutline} > open @@ -89,12 +94,11 @@ function FilterToolbar({ query, queryLocation }) { <CountingFilter active={hasValue('status', 'closed')} query={pipe( - params, replaceParam('status', 'closed'), clearParam('sort'), stringify - )} - to={pipe(params, replaceParam('status', 'closed'), loc)} + )(params)} + to={pipe(replaceParam('status', 'closed'), loc)(params)} icon={CheckCircleOutline} > closed @@ -104,7 +108,7 @@ function FilterToolbar({ query, queryLocation }) { <Filter active={hasKey('author')}>Author</Filter> <Filter active={hasKey('label')}>Label</Filter> */} - <Filter + <FilterDropdown dropdown={[ ['id', 'ID'], ['creation', 'Newest'], @@ -112,12 +116,11 @@ function FilterToolbar({ query, queryLocation }) { ['edit', 'Recently updated'], ['edit-asc', 'Least recently updated'], ]} - active={hasKey('sort')} itemActive={key => hasValue('sort', key)} - to={key => pipe(params, replaceParam('sort', key), loc)} + to={key => pipe(replaceParam('sort', key), loc)(params)} > Sort - </Filter> + </FilterDropdown> </Toolbar> ); } diff --git a/webui/src/list/List.js b/webui/src/list/List.tsx index 63b73545..cebd13f2 100644 --- a/webui/src/list/List.js +++ b/webui/src/list/List.tsx @@ -1,9 +1,12 @@ import Table from '@material-ui/core/Table/Table'; import TableBody from '@material-ui/core/TableBody/TableBody'; import React from 'react'; + import BugRow from './BugRow'; +import { BugListFragment } from './ListQuery.generated'; -function List({ bugs }) { +type Props = { bugs: BugListFragment }; +function List({ bugs }: Props) { return ( <Table> <TableBody> diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql new file mode 100644 index 00000000..ded60c8a --- /dev/null +++ b/webui/src/list/ListQuery.graphql @@ -0,0 +1,37 @@ +#import "./BugRow.graphql" + +query ListBugs( + $first: Int + $last: Int + $after: String + $before: String + $query: String +) { + repository { + bugs: allBugs( + first: $first + last: $last + after: $after + before: $before + query: $query + ) { + ...BugList + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +} + +fragment BugList on BugConnection { + totalCount + edges { + cursor + node { + ...BugRow + } + } +} diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.tsx index 8eeec240..84b72431 100644 --- a/webui/src/list/ListQuery.js +++ b/webui/src/list/ListQuery.tsx @@ -1,20 +1,21 @@ -import { fade, makeStyles } from '@material-ui/core/styles'; import IconButton from '@material-ui/core/IconButton'; +import InputBase from '@material-ui/core/InputBase'; +import Paper from '@material-ui/core/Paper'; +import { fade, makeStyles, Theme } from '@material-ui/core/styles'; +import ErrorOutline from '@material-ui/icons/ErrorOutline'; import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import Paper from '@material-ui/core/Paper'; -import InputBase from '@material-ui/core/InputBase'; import Skeleton from '@material-ui/lab/Skeleton'; -import gql from 'graphql-tag'; +import { ApolloError } from 'apollo-boost'; import React, { useState, useEffect, useRef } from 'react'; -import { useQuery } from '@apollo/react-hooks'; import { useLocation, useHistory, Link } from 'react-router-dom'; -import BugRow from './BugRow'; -import List from './List'; + import FilterToolbar from './FilterToolbar'; +import List from './List'; +import { useListBugsQuery } from './ListQuery.generated'; -const useStyles = makeStyles(theme => ({ +type StylesProps = { searching?: boolean }; +const useStyles = makeStyles<Theme, StylesProps>(theme => ({ main: { maxWidth: 800, margin: 'auto', @@ -46,7 +47,11 @@ const useStyles = makeStyles(theme => ({ backgroundColor: fade(theme.palette.primary.main, 0.05), padding: theme.spacing(0, 1), width: ({ searching }) => (searching ? '20rem' : '15rem'), - transition: theme.transitions.create(), + transition: theme.transitions.create([ + 'width', + 'borderColor', + 'backgroundColor', + ]), }, searchFocused: { borderColor: fade(theme.palette.primary.main, 0.4), @@ -91,51 +96,21 @@ const useStyles = makeStyles(theme => ({ }, })); -const QUERY = gql` - query( - $first: Int - $last: Int - $after: String - $before: String - $query: String - ) { - defaultRepository { - bugs: allBugs( - first: $first - last: $last - after: $after - before: $before - query: $query - ) { - totalCount - edges { - cursor - node { - ...BugRow - } - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } - } - } - - ${BugRow.fragment} -`; - -function editParams(params, callback) { +function editParams( + params: URLSearchParams, + callback: (params: URLSearchParams) => void +) { const cloned = new URLSearchParams(params.toString()); callback(cloned); return cloned; } // TODO: factor this out -const Placeholder = ({ count }) => { - const classes = useStyles(); +type PlaceholderProps = { count: number }; +const Placeholder: React.FC<PlaceholderProps> = ({ + count, +}: PlaceholderProps) => { + const classes = useStyles({}); return ( <> {new Array(count).fill(null).map((_, i) => ( @@ -158,7 +133,7 @@ const Placeholder = ({ count }) => { // TODO: factor this out const NoBug = () => { - const classes = useStyles(); + const classes = useStyles({}); return ( <div className={classes.message}> <ErrorOutline fontSize="large" /> @@ -167,8 +142,9 @@ const NoBug = () => { ); }; -const Error = ({ error }) => { - const classes = useStyles(); +type ErrorProps = { error: ApolloError }; +const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => { + const classes = useStyles({}); return ( <div className={[classes.errorBox, classes.message].join(' ')}> <ErrorOutline fontSize="large" /> @@ -194,7 +170,7 @@ function ListQuery() { const classes = useStyles({ searching: !!input }); // TODO is this the right way to do it? - const lastQuery = useRef(); + const lastQuery = useRef<string | null>(null); useEffect(() => { if (query !== lastQuery.current) { setInput(query); @@ -202,9 +178,10 @@ function ListQuery() { lastQuery.current = query; }, [query, input, lastQuery]); + const num = (param: string | null) => (param ? parseInt(param) : null); const page = { - first: params.get('first'), - last: params.get('last'), + first: num(params.get('first')), + last: num(params.get('last')), after: params.get('after'), before: params.get('before'), }; @@ -214,9 +191,9 @@ function ListQuery() { page.first = 10; } - const perPage = page.first || page.last; + const perPage = (page.first || page.last || 10).toString(); - const { loading, error, data } = useQuery(QUERY, { + const { loading, error, data } = useListBugsQuery({ variables: { ...page, query, @@ -225,34 +202,34 @@ function ListQuery() { let nextPage = null; let previousPage = null; - let hasNextPage = false; - let hasPreviousPage = false; let count = 0; - if (!loading && !error && data.defaultRepository.bugs) { - const bugs = data.defaultRepository.bugs; - hasNextPage = bugs.pageInfo.hasNextPage; - hasPreviousPage = bugs.pageInfo.hasPreviousPage; + if (!loading && !error && data?.repository?.bugs) { + const bugs = data.repository.bugs; count = bugs.totalCount; // This computes the URL for the next page - nextPage = { - ...location, - search: editParams(params, p => { - p.delete('last'); - p.delete('before'); - p.set('first', perPage); - p.set('after', bugs.pageInfo.endCursor); - }).toString(), - }; + if (bugs.pageInfo.hasNextPage) { + nextPage = { + ...location, + search: editParams(params, p => { + p.delete('last'); + p.delete('before'); + p.set('first', perPage); + p.set('after', bugs.pageInfo.endCursor); + }).toString(), + }; + } // and this for the previous page - previousPage = { - ...location, - search: editParams(params, p => { - p.delete('first'); - p.delete('after'); - p.set('last', perPage); - p.set('before', bugs.pageInfo.startCursor); - }).toString(), - }; + if (bugs.pageInfo.hasPreviousPage) { + previousPage = { + ...location, + search: editParams(params, p => { + p.delete('first'); + p.delete('after'); + p.set('last', perPage); + p.set('before', bugs.pageInfo.startCursor); + }).toString(), + }; + } } // Prepare params without paging for editing filters @@ -263,7 +240,7 @@ function ListQuery() { p.delete('after'); }); // Returns a new location with the `q` param edited - const queryLocation = query => ({ + const queryLocation = (query: string) => ({ ...location, search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(), }); @@ -273,8 +250,8 @@ function ListQuery() { content = <Placeholder count={10} />; } else if (error) { content = <Error error={error} />; - } else { - const bugs = data.defaultRepository.bugs; + } else if (data?.repository) { + const bugs = data.repository.bugs; if (bugs.totalCount === 0) { content = <NoBug />; @@ -283,7 +260,7 @@ function ListQuery() { } } - const formSubmit = e => { + const formSubmit = (e: React.FormEvent) => { e.preventDefault(); history.push(queryLocation(input)); }; @@ -296,7 +273,7 @@ function ListQuery() { <InputBase placeholder="Filter" value={input} - onInput={e => setInput(e.target.value)} + onInput={(e: any) => setInput(e.target.value)} classes={{ root: classes.search, focused: classes.searchFocused, @@ -310,21 +287,25 @@ function ListQuery() { <FilterToolbar query={query} queryLocation={queryLocation} /> {content} <div className={classes.pagination}> - <IconButton - component={hasPreviousPage ? Link : 'button'} - to={previousPage} - disabled={!hasPreviousPage} - > - <KeyboardArrowLeft /> - </IconButton> + {previousPage ? ( + <IconButton component={Link} to={previousPage}> + <KeyboardArrowLeft /> + </IconButton> + ) : ( + <IconButton disabled> + <KeyboardArrowLeft /> + </IconButton> + )} <div>{loading ? 'Loading' : `Total: ${count}`}</div> - <IconButton - component={hasNextPage ? Link : 'button'} - to={nextPage} - disabled={!hasNextPage} - > - <KeyboardArrowRight /> - </IconButton> + {nextPage ? ( + <IconButton component={Link} to={nextPage}> + <KeyboardArrowRight /> + </IconButton> + ) : ( + <IconButton disabled> + <KeyboardArrowRight /> + </IconButton> + )} </div> </Paper> ); |