diff options
author | Quentin Gliech <quentingliech@gmail.com> | 2020-02-13 20:00:03 +0100 |
---|---|---|
committer | Quentin Gliech <quentingliech@gmail.com> | 2020-02-13 20:00:03 +0100 |
commit | ce6f6a984b374b189141116433ced80dfa0c2aae (patch) | |
tree | e6487b9b480e6b18767ae310b702b57e5cbef000 /webui/src/list | |
parent | 8b85780d76ad45675582f4478eedb026b7ac25e1 (diff) | |
download | git-bug-ce6f6a984b374b189141116433ced80dfa0c2aae.tar.gz |
webui: move pages components
Diffstat (limited to 'webui/src/list')
-rw-r--r-- | webui/src/list/BugRow.graphql | 13 | ||||
-rw-r--r-- | webui/src/list/BugRow.tsx | 112 | ||||
-rw-r--r-- | webui/src/list/Filter.tsx | 189 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.graphql | 7 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.tsx | 128 | ||||
-rw-r--r-- | webui/src/list/List.tsx | 21 | ||||
-rw-r--r-- | webui/src/list/ListQuery.graphql | 37 | ||||
-rw-r--r-- | webui/src/list/ListQuery.tsx | 314 |
8 files changed, 0 insertions, 821 deletions
diff --git a/webui/src/list/BugRow.graphql b/webui/src/list/BugRow.graphql deleted file mode 100644 index c2966f10..00000000 --- a/webui/src/list/BugRow.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "../components/fragments.graphql" - -fragment BugRow on Bug { - id - humanId - title - status - createdAt - labels { - ...Label - } - ...authored -} diff --git a/webui/src/list/BugRow.tsx b/webui/src/list/BugRow.tsx deleted file mode 100644 index 181aec2e..00000000 --- a/webui/src/list/BugRow.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 { makeStyles } from '@material-ui/core/styles'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import React from 'react'; -import { Link } from 'react-router-dom'; - -import Date from '../components/Date'; -import Label from '../components/Label'; -import { Status } from '../gqlTypes'; - -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 }: OpenClosedProps) => ( - <Tooltip title="Closed"> - <CheckCircleOutline htmlColor="#cb2431" className={className} /> - </Tooltip> -); - -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 <p>{'unknown status ' + status}</p>; - } -}; - -const useStyles = makeStyles(theme => ({ - cell: { - display: 'flex', - alignItems: 'center', - padding: theme.spacing(1), - '& a': { - textDecoration: 'none', - }, - }, - status: { - margin: theme.spacing(1, 2), - }, - expand: { - width: '100%', - lineHeight: '20px', - }, - title: { - display: 'inline', - color: theme.palette.text.primary, - fontSize: '1.3rem', - fontWeight: 500, - }, - details: { - lineHeight: '1.5rem', - color: theme.palette.text.secondary, - }, - labels: { - paddingLeft: theme.spacing(1), - '& > *': { - display: 'inline-block', - }, - }, -})); - -type Props = { - bug: BugRowFragment; -}; - -function BugRow({ bug }: Props) { - const classes = useStyles(); - return ( - <TableRow hover> - <TableCell className={classes.cell}> - <BugStatus status={bug.status} className={classes.status} /> - <div className={classes.expand}> - <Link to={'bug/' + bug.humanId}> - <div className={classes.expand}> - <span className={classes.title}>{bug.title}</span> - {bug.labels.length > 0 && ( - <span className={classes.labels}> - {bug.labels.map(l => ( - <Label key={l.name} label={l} /> - ))} - </span> - )} - </div> - </Link> - <div className={classes.details}> - {bug.humanId} opened - <Date date={bug.createdAt} /> - by {bug.author.displayName} - </div> - </div> - </TableCell> - </TableRow> - ); -} - -export default BugRow; diff --git a/webui/src/list/Filter.tsx b/webui/src/list/Filter.tsx deleted file mode 100644 index 30b52de8..00000000 --- a/webui/src/list/Filter.tsx +++ /dev/null @@ -1,189 +0,0 @@ -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'; - -export type Query = { [key: string]: Array<string> }; - -function parse(query: string): Query { - // TODO: extract the rest of the query? - const params: Query = {}; - - // TODO: support escaping without quotes - const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; - let matches; - while ((matches = re.exec(query)) !== null) { - if (!params[matches[1]]) { - params[matches[1]] = []; - } - - let value; - if (matches[4]) { - value = matches[4]; - } else { - value = matches[2]; - } - value = value.replace(/\\(.)/g, '$1'); - params[matches[1]].push(value); - } - return params; -} - -function quote(value: string): string { - const hasSingle = value.includes("'"); - const hasDouble = value.includes('"'); - const hasSpaces = value.includes(' '); - if (!hasSingle && !hasDouble && !hasSpaces) { - return value; - } - - if (!hasDouble) { - return `"${value}"`; - } - - if (!hasSingle) { - return `'${value}'`; - } - - value = value.replace(/"/g, '\\"'); - return `"${value}"`; -} - -function stringify(params: Query): string { - const parts: string[][] = Object.entries(params).map(([key, values]) => { - return values.map(value => `${key}:${quote(value)}`); - }); - return new Array<string>().concat(...parts).join(' '); -} - -const useStyles = makeStyles(theme => ({ - element: { - ...theme.typography.body2, - color: '#444', - padding: theme.spacing(0, 1), - fontWeight: 400, - textDecoration: 'none', - display: 'flex', - background: 'none', - border: 'none', - }, - itemActive: { - fontWeight: 600, - color: '#333', - }, - icon: { - paddingRight: theme.spacing(0.5), - }, -})); - -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<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)} - className={classes.element} - {...props} - > - {content} - <ArrowDropDown fontSize="small" /> - </button> - <Menu - getContentAnchorEl={null} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - open={open} - onClose={() => setOpen(false)} - anchorEl={buttonRef.current} - > - {dropdown.map(([key, value]) => ( - <MenuItem - component={Link} - to={to(key)} - className={itemActive(key) ? classes.itemActive : undefined} - onClick={() => setOpen(false)} - key={key} - > - {value} - </MenuItem> - ))} - </Menu> - </> - ); -} - -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 = ( - <> - {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />} - <div>{children}</div> - </> - ); - - if (to) { - return ( - <Link - to={to} - className={clsx(classes.element, active && classes.itemActive)} - > - {content} - </Link> - ); - } - - return ( - <div className={clsx(classes.element, active && classes.itemActive)}> - {content} - </div> - ); -} - -export default Filter; -export { parse, stringify, quote, FilterDropdown, Filter }; diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql deleted file mode 100644 index cd103f44..00000000 --- a/webui/src/list/FilterToolbar.graphql +++ /dev/null @@ -1,7 +0,0 @@ -query BugCount($query: String) { - repository { - bugs: allBugs(query: $query) { - totalCount - } - } -} diff --git a/webui/src/list/FilterToolbar.tsx b/webui/src/list/FilterToolbar.tsx deleted file mode 100644 index b95b10bc..00000000 --- a/webui/src/list/FilterToolbar.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { pipe } from '@arrows/composition'; -import Toolbar from '@material-ui/core/Toolbar'; -import { makeStyles } from '@material-ui/core/styles'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import { LocationDescriptor } from 'history'; -import React from 'react'; - -import { - FilterDropdown, - FilterProps, - Filter, - parse, - stringify, - Query, -} from './Filter'; -import { useBugCountQuery } from './FilterToolbar.generated'; - -const useStyles = makeStyles(theme => ({ - toolbar: { - backgroundColor: theme.palette.grey['100'], - borderColor: theme.palette.grey['300'], - borderWidth: '1px 0', - borderStyle: 'solid', - margin: theme.spacing(0, -1), - }, - spacer: { - flex: 1, - }, -})); - -// This prepends the filter text with a count -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 || !data?.repository) prefix = '???'; - // TODO: better prefixes & error handling - else prefix = data.repository.bugs.totalCount; - - return ( - <Filter {...props}> - {prefix} {children} - </Filter> - ); -} - -type Props = { - query: string; - queryLocation: (query: string) => LocationDescriptor; -}; -function FilterToolbar({ query, queryLocation }: Props) { - const classes = useStyles(); - const params: Query = parse(query); - - 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: string) => (params: Query): Query => ({ - ...params, - [key]: [], - }); - - // TODO: author/label filters - return ( - <Toolbar className={classes.toolbar}> - <CountingFilter - active={hasValue('status', 'open')} - query={pipe( - replaceParam('status', 'open'), - clearParam('sort'), - stringify - )(params)} - to={pipe(replaceParam('status', 'open'), loc)(params)} - icon={ErrorOutline} - > - open - </CountingFilter> - <CountingFilter - active={hasValue('status', 'closed')} - query={pipe( - replaceParam('status', 'closed'), - clearParam('sort'), - stringify - )(params)} - to={pipe(replaceParam('status', 'closed'), loc)(params)} - icon={CheckCircleOutline} - > - closed - </CountingFilter> - <div className={classes.spacer} /> - {/* - <Filter active={hasKey('author')}>Author</Filter> - <Filter active={hasKey('label')}>Label</Filter> - */} - <FilterDropdown - dropdown={[ - ['id', 'ID'], - ['creation', 'Newest'], - ['creation-asc', 'Oldest'], - ['edit', 'Recently updated'], - ['edit-asc', 'Least recently updated'], - ]} - itemActive={key => hasValue('sort', key)} - to={key => pipe(replaceParam('sort', key), loc)(params)} - > - Sort - </FilterDropdown> - </Toolbar> - ); -} - -export default FilterToolbar; diff --git a/webui/src/list/List.tsx b/webui/src/list/List.tsx deleted file mode 100644 index cebd13f2..00000000 --- a/webui/src/list/List.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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'; - -type Props = { bugs: BugListFragment }; -function List({ bugs }: Props) { - return ( - <Table> - <TableBody> - {bugs.edges.map(({ cursor, node }) => ( - <BugRow bug={node} key={cursor} /> - ))} - </TableBody> - </Table> - ); -} - -export default List; diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql deleted file mode 100644 index ded60c8a..00000000 --- a/webui/src/list/ListQuery.graphql +++ /dev/null @@ -1,37 +0,0 @@ -#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.tsx b/webui/src/list/ListQuery.tsx deleted file mode 100644 index 84b72431..00000000 --- a/webui/src/list/ListQuery.tsx +++ /dev/null @@ -1,314 +0,0 @@ -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 Skeleton from '@material-ui/lab/Skeleton'; -import { ApolloError } from 'apollo-boost'; -import React, { useState, useEffect, useRef } from 'react'; -import { useLocation, useHistory, Link } from 'react-router-dom'; - -import FilterToolbar from './FilterToolbar'; -import List from './List'; -import { useListBugsQuery } from './ListQuery.generated'; - -type StylesProps = { searching?: boolean }; -const useStyles = makeStyles<Theme, StylesProps>(theme => ({ - main: { - maxWidth: 800, - margin: 'auto', - marginTop: theme.spacing(4), - marginBottom: theme.spacing(4), - overflow: 'hidden', - }, - pagination: { - ...theme.typography.overline, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - header: { - display: 'flex', - padding: theme.spacing(2), - '& > h1': { - ...theme.typography.h6, - margin: theme.spacing(0, 2), - }, - alignItems: 'center', - justifyContent: 'space-between', - }, - search: { - borderRadius: theme.shape.borderRadius, - borderColor: fade(theme.palette.primary.main, 0.2), - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: fade(theme.palette.primary.main, 0.05), - padding: theme.spacing(0, 1), - width: ({ searching }) => (searching ? '20rem' : '15rem'), - transition: theme.transitions.create([ - 'width', - 'borderColor', - 'backgroundColor', - ]), - }, - searchFocused: { - borderColor: fade(theme.palette.primary.main, 0.4), - backgroundColor: theme.palette.background.paper, - width: '20rem!important', - }, - placeholderRow: { - padding: theme.spacing(1), - borderBottomColor: theme.palette.grey['300'], - borderBottomWidth: '1px', - borderBottomStyle: 'solid', - display: 'flex', - alignItems: 'center', - }, - placeholderRowStatus: { - margin: theme.spacing(1, 2), - }, - placeholderRowText: { - flex: 1, - }, - message: { - ...theme.typography.h5, - padding: theme.spacing(8), - textAlign: 'center', - borderBottomColor: theme.palette.grey['300'], - borderBottomWidth: '1px', - borderBottomStyle: 'solid', - '& > p': { - margin: '0', - }, - }, - errorBox: { - color: theme.palette.error.main, - '& > pre': { - fontSize: '1rem', - textAlign: 'left', - backgroundColor: theme.palette.grey['900'], - color: theme.palette.common.white, - marginTop: theme.spacing(4), - padding: theme.spacing(2, 3), - }, - }, -})); - -function editParams( - params: URLSearchParams, - callback: (params: URLSearchParams) => void -) { - const cloned = new URLSearchParams(params.toString()); - callback(cloned); - return cloned; -} - -// TODO: factor this out -type PlaceholderProps = { count: number }; -const Placeholder: React.FC<PlaceholderProps> = ({ - count, -}: PlaceholderProps) => { - const classes = useStyles({}); - return ( - <> - {new Array(count).fill(null).map((_, i) => ( - <div key={i} className={classes.placeholderRow}> - <Skeleton - className={classes.placeholderRowStatus} - variant="circle" - width={20} - height={20} - /> - <div className={classes.placeholderRowText}> - <Skeleton height={22} /> - <Skeleton height={24} width="60%" /> - </div> - </div> - ))} - </> - ); -}; - -// TODO: factor this out -const NoBug = () => { - const classes = useStyles({}); - return ( - <div className={classes.message}> - <ErrorOutline fontSize="large" /> - <p>No results matched your search.</p> - </div> - ); -}; - -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" /> - <p>There was an error while fetching bug.</p> - <p> - <em>{error.message}</em> - </p> - <pre> - <code>{JSON.stringify(error, null, 2)}</code> - </pre> - </div> - ); -}; - -function ListQuery() { - const location = useLocation(); - const history = useHistory(); - const params = new URLSearchParams(location.search); - const query = params.get('q') || ''; - - const [input, setInput] = useState(query); - - const classes = useStyles({ searching: !!input }); - - // TODO is this the right way to do it? - const lastQuery = useRef<string | null>(null); - useEffect(() => { - if (query !== lastQuery.current) { - setInput(query); - } - lastQuery.current = query; - }, [query, input, lastQuery]); - - const num = (param: string | null) => (param ? parseInt(param) : null); - const page = { - first: num(params.get('first')), - last: num(params.get('last')), - after: params.get('after'), - before: params.get('before'), - }; - - // If nothing set, show the first 10 items - if (!page.first && !page.last) { - page.first = 10; - } - - const perPage = (page.first || page.last || 10).toString(); - - const { loading, error, data } = useListBugsQuery({ - variables: { - ...page, - query, - }, - }); - - let nextPage = null; - let previousPage = null; - let count = 0; - if (!loading && !error && data?.repository?.bugs) { - const bugs = data.repository.bugs; - count = bugs.totalCount; - // This computes the URL for the next page - 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 - 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 - const paramsWithoutPaging = editParams(params, p => { - p.delete('first'); - p.delete('last'); - p.delete('before'); - p.delete('after'); - }); - // Returns a new location with the `q` param edited - const queryLocation = (query: string) => ({ - ...location, - search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(), - }); - - let content; - if (loading) { - content = <Placeholder count={10} />; - } else if (error) { - content = <Error error={error} />; - } else if (data?.repository) { - const bugs = data.repository.bugs; - - if (bugs.totalCount === 0) { - content = <NoBug />; - } else { - content = <List bugs={bugs} />; - } - } - - const formSubmit = (e: React.FormEvent) => { - e.preventDefault(); - history.push(queryLocation(input)); - }; - - return ( - <Paper className={classes.main}> - <header className={classes.header}> - <h1>Issues</h1> - <form onSubmit={formSubmit}> - <InputBase - placeholder="Filter" - value={input} - onInput={(e: any) => setInput(e.target.value)} - classes={{ - root: classes.search, - focused: classes.searchFocused, - }} - /> - <button type="submit" hidden> - Search - </button> - </form> - </header> - <FilterToolbar query={query} queryLocation={queryLocation} /> - {content} - <div className={classes.pagination}> - {previousPage ? ( - <IconButton component={Link} to={previousPage}> - <KeyboardArrowLeft /> - </IconButton> - ) : ( - <IconButton disabled> - <KeyboardArrowLeft /> - </IconButton> - )} - <div>{loading ? 'Loading' : `Total: ${count}`}</div> - {nextPage ? ( - <IconButton component={Link} to={nextPage}> - <KeyboardArrowRight /> - </IconButton> - ) : ( - <IconButton disabled> - <KeyboardArrowRight /> - </IconButton> - )} - </div> - </Paper> - ); -} - -export default ListQuery; |