diff options
Diffstat (limited to 'webui/src/pages')
28 files changed, 1520 insertions, 0 deletions
diff --git a/webui/src/pages/bug/Bug.graphql b/webui/src/pages/bug/Bug.graphql new file mode 100644 index 00000000..498242c0 --- /dev/null +++ b/webui/src/pages/bug/Bug.graphql @@ -0,0 +1,13 @@ +#import "../components/fragments.graphql" + +fragment Bug on Bug { + id + humanId + status + title + labels { + ...Label + } + createdAt + ...authored +} diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx new file mode 100644 index 00000000..1bc128dd --- /dev/null +++ b/webui/src/pages/bug/Bug.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import Typography from '@material-ui/core/Typography/Typography'; +import { makeStyles } from '@material-ui/core/styles'; + +import Author from 'src/components/Author'; +import Date from 'src/components/Date'; +import Label from 'src/components/Label'; + +import { BugFragment } from './Bug.generated'; +import CommentForm from './CommentForm'; +import TimelineQuery from './TimelineQuery'; + +const useStyles = makeStyles(theme => ({ + main: { + maxWidth: 1000, + margin: 'auto', + marginTop: theme.spacing(4), + }, + header: { + marginLeft: theme.spacing(3) + 40, + }, + title: { + ...theme.typography.h5, + }, + id: { + ...theme.typography.subtitle1, + marginLeft: theme.spacing(1), + }, + container: { + display: 'flex', + marginBottom: theme.spacing(1), + marginRight: theme.spacing(2), + marginLeft: theme.spacing(2), + }, + timeline: { + flex: 1, + marginTop: theme.spacing(2), + marginRight: theme.spacing(2), + minWidth: 400, + }, + sidebar: { + marginTop: theme.spacing(2), + flex: '0 0 200px', + }, + sidebarTitle: { + fontWeight: 'bold', + }, + labelList: { + listStyle: 'none', + padding: 0, + margin: 0, + }, + label: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + '& > *': { + display: 'block', + }, + }, + noLabel: { + ...theme.typography.body2, + }, + commentForm: { + marginLeft: 48, + }, +})); + +type Props = { + bug: BugFragment; +}; + +function Bug({ bug }: Props) { + const classes = useStyles(); + return ( + <main className={classes.main}> + <div className={classes.header}> + <span className={classes.title}>{bug.title}</span> + <span className={classes.id}>{bug.humanId}</span> + + <Typography color={'textSecondary'}> + <Author author={bug.author} /> + {' opened this bug '} + <Date date={bug.createdAt} /> + </Typography> + </div> + + <div className={classes.container}> + <div className={classes.timeline}> + <TimelineQuery id={bug.id} /> + <div className={classes.commentForm}> + <CommentForm bugId={bug.id} /> + </div> + </div> + <div className={classes.sidebar}> + <span className={classes.sidebarTitle}>Labels</span> + <ul className={classes.labelList}> + {bug.labels.length === 0 && ( + <span className={classes.noLabel}>None yet</span> + )} + {bug.labels.map(l => ( + <li className={classes.label} key={l.name}> + <Label label={l} key={l.name} /> + </li> + ))} + </ul> + </div> + </div> + </main> + ); +} + +export default Bug; diff --git a/webui/src/pages/bug/BugQuery.graphql b/webui/src/pages/bug/BugQuery.graphql new file mode 100644 index 00000000..cdc4723f --- /dev/null +++ b/webui/src/pages/bug/BugQuery.graphql @@ -0,0 +1,9 @@ +#import "./Bug.graphql" + +query GetBug($id: String!) { + repository { + bug(prefix: $id) { + ...Bug + } + } +} diff --git a/webui/src/pages/bug/BugQuery.tsx b/webui/src/pages/bug/BugQuery.tsx new file mode 100644 index 00000000..2a70a2f8 --- /dev/null +++ b/webui/src/pages/bug/BugQuery.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; + +import CircularProgress from '@material-ui/core/CircularProgress'; + +import Bug from './Bug'; +import { useGetBugQuery } from './BugQuery.generated'; + +type Props = RouteComponentProps<{ + id: string; +}>; + +const BugQuery: React.FC<Props> = ({ match }: Props) => { + const { loading, error, data } = useGetBugQuery({ + variables: { id: match.params.id }, + }); + if (loading) return <CircularProgress />; + if (error) return <p>Error: {error}</p>; + if (!data?.repository?.bug) return <p>404.</p>; + return <Bug bug={data.repository.bug} />; +}; + +export default BugQuery; diff --git a/webui/src/pages/bug/CommentForm.graphql b/webui/src/pages/bug/CommentForm.graphql new file mode 100644 index 00000000..33d21193 --- /dev/null +++ b/webui/src/pages/bug/CommentForm.graphql @@ -0,0 +1,5 @@ +mutation AddComment($input: AddCommentInput!) { + addComment(input: $input) { + operation { id } + } +} diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx new file mode 100644 index 00000000..3724baf0 --- /dev/null +++ b/webui/src/pages/bug/CommentForm.tsx @@ -0,0 +1,146 @@ +import React, { useState, useRef } from 'react'; + +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; +import TextField from '@material-ui/core/TextField'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +import Content from 'src/components/Content'; + +import { useAddCommentMutation } from './CommentForm.generated'; +import { TimelineDocument } from './TimelineQuery.generated'; + +type StyleProps = { loading: boolean }; +const useStyles = makeStyles<Theme, StyleProps>(theme => ({ + container: { + margin: theme.spacing(2, 0), + padding: theme.spacing(0, 2, 2, 2), + }, + textarea: {}, + tabContent: { + margin: theme.spacing(2, 0), + }, + preview: { + borderBottom: `solid 3px ${theme.palette.grey['200']}`, + minHeight: '5rem', + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, +})); + +type TabPanelProps = { + children: React.ReactNode; + value: number; + index: number; +} & React.HTMLProps<HTMLDivElement>; +function TabPanel({ children, value, index, ...props }: TabPanelProps) { + return ( + <div + role="tabpanel" + hidden={value !== index} + id={`editor-tabpanel-${index}`} + aria-labelledby={`editor-tab-${index}`} + {...props} + > + {value === index && children} + </div> + ); +} + +const a11yProps = (index: number) => ({ + id: `editor-tab-${index}`, + 'aria-controls': `editor-tabpanel-${index}`, +}); + +type Props = { + bugId: string; +}; + +function CommentForm({ bugId }: Props) { + const [addComment, { loading }] = useAddCommentMutation(); + const [input, setInput] = useState<string>(''); + const [tab, setTab] = useState(0); + const classes = useStyles({ loading }); + const form = useRef<HTMLFormElement>(null); + + const submit = () => { + addComment({ + variables: { + input: { + prefix: bugId, + message: input, + }, + }, + refetchQueries: [ + // TODO: update the cache instead of refetching + { + query: TimelineDocument, + variables: { + id: bugId, + first: 100, + }, + }, + ], + awaitRefetchQueries: true, + }).then(() => setInput('')); + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + submit(); + }; + + const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => { + // Submit on cmd/ctrl+enter + if ((e.metaKey || e.altKey) && e.keyCode === 13) { + submit(); + } + }; + + return ( + <Paper className={classes.container}> + <form onSubmit={handleSubmit} ref={form}> + <Tabs value={tab} onChange={(_, t) => setTab(t)}> + <Tab label="Write" {...a11yProps(0)} /> + <Tab label="Preview" {...a11yProps(1)} /> + </Tabs> + <div className={classes.tabContent}> + <TabPanel value={tab} index={0}> + <TextField + onKeyDown={handleKeyDown} + fullWidth + label="Comment" + placeholder="Leave a comment" + className={classes.textarea} + multiline + value={input} + variant="filled" + rows="4" // TODO: rowsMin support + onChange={(e: any) => setInput(e.target.value)} + disabled={loading} + /> + </TabPanel> + <TabPanel value={tab} index={1} className={classes.preview}> + <Content markdown={input} /> + </TabPanel> + </div> + <div className={classes.actions}> + <Button + variant="contained" + color="primary" + type="submit" + disabled={loading} + > + Comment + </Button> + </div> + </form> + </Paper> + ); +} + +export default CommentForm; diff --git a/webui/src/pages/bug/LabelChange.tsx b/webui/src/pages/bug/LabelChange.tsx new file mode 100644 index 00000000..93fa8a32 --- /dev/null +++ b/webui/src/pages/bug/LabelChange.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +import Author from 'src/components/Author'; +import Date from 'src/components/Date'; +import Label from 'src/components/Label'; + +import { LabelChangeFragment } from './LabelChangeFragment.generated'; + +const useStyles = makeStyles(theme => ({ + main: { + ...theme.typography.body2, + marginLeft: theme.spacing(1) + 40, + }, + author: { + fontWeight: 'bold', + }, +})); + +type Props = { + op: LabelChangeFragment; +}; + +function LabelChange({ op }: Props) { + const { added, removed } = op; + const classes = useStyles(); + return ( + <div className={classes.main}> + <Author author={op.author} className={classes.author} /> + {added.length > 0 && <span> added the </span>} + {added.map((label, index) => ( + <Label key={index} label={label} /> + ))} + {added.length > 0 && removed.length > 0 && <span> and</span>} + {removed.length > 0 && <span> removed the </span>} + {removed.map((label, index) => ( + <Label key={index} label={label} /> + ))} + <span> + {' '} + label + {added.length + removed.length > 1 && 's'}{' '} + </span> + <Date date={op.date} /> + </div> + ); +} + +export default LabelChange; diff --git a/webui/src/pages/bug/LabelChangeFragment.graphql b/webui/src/pages/bug/LabelChangeFragment.graphql new file mode 100644 index 00000000..82d41235 --- /dev/null +++ b/webui/src/pages/bug/LabelChangeFragment.graphql @@ -0,0 +1,12 @@ +#import "../../components/fragments.graphql" + +fragment LabelChange on LabelChangeTimelineItem { + date + ...authored + added { + ...Label + } + removed { + ...Label + } +} diff --git a/webui/src/pages/bug/Message.tsx b/webui/src/pages/bug/Message.tsx new file mode 100644 index 00000000..4a438b77 --- /dev/null +++ b/webui/src/pages/bug/Message.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +import Paper from '@material-ui/core/Paper'; +import { makeStyles } from '@material-ui/core/styles'; + +import Author, { Avatar } from 'src/components/Author'; +import Content from 'src/components/Content'; +import Date from 'src/components/Date'; + +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; + +const useStyles = makeStyles(theme => ({ + author: { + fontWeight: 'bold', + }, + container: { + display: 'flex', + }, + avatar: { + marginTop: 2, + }, + bubble: { + flex: 1, + marginLeft: theme.spacing(1), + minWidth: 0, + }, + header: { + ...theme.typography.body1, + color: '#444', + padding: '0.5rem 1rem', + borderBottom: '1px solid #ddd', + display: 'flex', + backgroundColor: '#e2f1ff', + }, + title: { + flex: 1, + }, + tag: { + ...theme.typography.button, + color: '#888', + border: '#ddd solid 1px', + padding: '0 0.5rem', + fontSize: '0.75rem', + borderRadius: 2, + marginLeft: '0.5rem', + }, + body: { + ...theme.typography.body2, + padding: '0 1rem', + }, +})); + +type Props = { + op: AddCommentFragment | CreateFragment; +}; + +function Message({ op }: Props) { + const classes = useStyles(); + return ( + <article className={classes.container}> + <Avatar author={op.author} className={classes.avatar} /> + <Paper elevation={1} className={classes.bubble}> + <header className={classes.header}> + <div className={classes.title}> + <Author className={classes.author} author={op.author} /> + <span> commented </span> + <Date date={op.createdAt} /> + </div> + {op.edited && <div className={classes.tag}>Edited</div>} + </header> + <section className={classes.body}> + <Content markdown={op.message} /> + </section> + </Paper> + </article> + ); +} + +export default Message; diff --git a/webui/src/pages/bug/MessageCommentFragment.graphql b/webui/src/pages/bug/MessageCommentFragment.graphql new file mode 100644 index 00000000..00f8342d --- /dev/null +++ b/webui/src/pages/bug/MessageCommentFragment.graphql @@ -0,0 +1,8 @@ +#import "../../components/fragments.graphql" + +fragment AddComment on AddCommentTimelineItem { + createdAt + ...authored + edited + message +} diff --git a/webui/src/pages/bug/MessageCreateFragment.graphql b/webui/src/pages/bug/MessageCreateFragment.graphql new file mode 100644 index 00000000..4cae819d --- /dev/null +++ b/webui/src/pages/bug/MessageCreateFragment.graphql @@ -0,0 +1,8 @@ +#import "../../components/fragments.graphql" + +fragment Create on CreateTimelineItem { + createdAt + ...authored + edited + message +} diff --git a/webui/src/pages/bug/SetStatus.tsx b/webui/src/pages/bug/SetStatus.tsx new file mode 100644 index 00000000..e9674424 --- /dev/null +++ b/webui/src/pages/bug/SetStatus.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +import { Status } from '../../gqlTypes'; +import Author from 'src/components/Author'; +import Date from 'src/components/Date'; + +import { SetStatusFragment } from './SetStatusFragment.generated'; + +const useStyles = makeStyles(theme => ({ + main: { + ...theme.typography.body2, + marginLeft: theme.spacing(1) + 40, + }, + author: { + fontWeight: 'bold', + }, +})); + +type Props = { + op: SetStatusFragment; +}; + +function SetStatus({ op }: Props) { + const classes = useStyles(); + const status = { [Status.Open]: 'reopened', [Status.Closed]: 'closed' }[ + op.status + ]; + + return ( + <div className={classes.main}> + <Author author={op.author} className={classes.author} /> + <span> {status} this </span> + <Date date={op.date} /> + </div> + ); +} + +export default SetStatus; diff --git a/webui/src/pages/bug/SetStatusFragment.graphql b/webui/src/pages/bug/SetStatusFragment.graphql new file mode 100644 index 00000000..d8380409 --- /dev/null +++ b/webui/src/pages/bug/SetStatusFragment.graphql @@ -0,0 +1,7 @@ +#import "../../components/fragments.graphql" + +fragment SetStatus on SetStatusTimelineItem { + date + ...authored + status +} diff --git a/webui/src/pages/bug/SetTitle.tsx b/webui/src/pages/bug/SetTitle.tsx new file mode 100644 index 00000000..64b97517 --- /dev/null +++ b/webui/src/pages/bug/SetTitle.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +import Author from 'src/components/Author'; +import Date from 'src/components/Date'; + +import { SetTitleFragment } from './SetTitleFragment.generated'; + +const useStyles = makeStyles(theme => ({ + main: { + ...theme.typography.body2, + marginLeft: theme.spacing(1) + 40, + }, + author: { + fontWeight: 'bold', + }, + before: { + fontWeight: 'bold', + textDecoration: 'line-through', + }, + after: { + fontWeight: 'bold', + }, +})); + +type Props = { + op: SetTitleFragment; +}; + +function SetTitle({ op }: Props) { + const classes = useStyles(); + return ( + <div className={classes.main}> + <Author author={op.author} className={classes.author} /> + <span> changed the title from </span> + <span className={classes.before}>{op.was}</span> + <span> to </span> + <span className={classes.after}>{op.title}</span> + <Date date={op.date} /> + </div> + ); +} + +export default SetTitle; diff --git a/webui/src/pages/bug/SetTitleFragment.graphql b/webui/src/pages/bug/SetTitleFragment.graphql new file mode 100644 index 00000000..2225dfd3 --- /dev/null +++ b/webui/src/pages/bug/SetTitleFragment.graphql @@ -0,0 +1,8 @@ +#import "../../components/fragments.graphql" + +fragment SetTitle on SetTitleTimelineItem { + date + ...authored + title + was +} diff --git a/webui/src/pages/bug/Timeline.tsx b/webui/src/pages/bug/Timeline.tsx new file mode 100644 index 00000000..73c88cdf --- /dev/null +++ b/webui/src/pages/bug/Timeline.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +import LabelChange from './LabelChange'; +import Message from './Message'; +import SetStatus from './SetStatus'; +import SetTitle from './SetTitle'; +import { TimelineItemFragment } from './TimelineQuery.generated'; + +const useStyles = makeStyles(theme => ({ + main: { + '& > *:not(:last-child)': { + marginBottom: theme.spacing(2), + }, + }, +})); + +type Props = { + ops: Array<TimelineItemFragment>; +}; + +function Timeline({ ops }: Props) { + const classes = useStyles(); + + return ( + <div className={classes.main}> + {ops.map((op, index) => { + switch (op.__typename) { + case 'CreateTimelineItem': + return <Message key={index} op={op} />; + case 'AddCommentTimelineItem': + return <Message key={index} op={op} />; + case 'LabelChangeTimelineItem': + return <LabelChange key={index} op={op} />; + case 'SetTitleTimelineItem': + return <SetTitle key={index} op={op} />; + case 'SetStatusTimelineItem': + return <SetStatus key={index} op={op} />; + } + + console.warn('unsupported operation type ' + op.__typename); + return null; + })} + </div> + ); +} + +export default Timeline; diff --git a/webui/src/pages/bug/TimelineQuery.graphql b/webui/src/pages/bug/TimelineQuery.graphql new file mode 100644 index 00000000..6d78ab7f --- /dev/null +++ b/webui/src/pages/bug/TimelineQuery.graphql @@ -0,0 +1,39 @@ +#import "./MessageCreateFragment.graphql" +#import "./MessageCommentFragment.graphql" +#import "./LabelChangeFragment.graphql" +#import "./SetTitleFragment.graphql" +#import "./SetStatusFragment.graphql" + +query Timeline($id: String!, $first: Int = 10, $after: String) { + repository { + bug(prefix: $id) { + timeline(first: $first, after: $after) { + nodes { + ...TimelineItem + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} + +fragment TimelineItem on TimelineItem { + ... on LabelChangeTimelineItem { + ...LabelChange + } + ... on SetStatusTimelineItem { + ...SetStatus + } + ... on SetTitleTimelineItem { + ...SetTitle + } + ... on AddCommentTimelineItem { + ...AddComment + } + ... on CreateTimelineItem { + ...Create + } +} diff --git a/webui/src/pages/bug/TimelineQuery.tsx b/webui/src/pages/bug/TimelineQuery.tsx new file mode 100644 index 00000000..74eed52b --- /dev/null +++ b/webui/src/pages/bug/TimelineQuery.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import CircularProgress from '@material-ui/core/CircularProgress'; + +import Timeline from './Timeline'; +import { useTimelineQuery } from './TimelineQuery.generated'; + +type Props = { + id: string; +}; + +const TimelineQuery = ({ id }: Props) => { + const { loading, error, data } = useTimelineQuery({ + variables: { + id, + first: 100, + }, + }); + + if (loading) return <CircularProgress />; + if (error) return <p>Error: {error}</p>; + + const nodes = data?.repository?.bug?.timeline.nodes; + if (!nodes) { + return null; + } + + return <Timeline ops={nodes} />; +}; + +export default TimelineQuery; diff --git a/webui/src/pages/bug/index.tsx b/webui/src/pages/bug/index.tsx new file mode 100644 index 00000000..a3bbcea4 --- /dev/null +++ b/webui/src/pages/bug/index.tsx @@ -0,0 +1 @@ +export { default } from './BugQuery'; diff --git a/webui/src/pages/list/BugRow.graphql b/webui/src/pages/list/BugRow.graphql new file mode 100644 index 00000000..547c09d8 --- /dev/null +++ b/webui/src/pages/list/BugRow.graphql @@ -0,0 +1,13 @@ +#import "../../components/fragments.graphql" + +fragment BugRow on Bug { + id + humanId + title + status + createdAt + labels { + ...Label + } + ...authored +} diff --git a/webui/src/pages/list/BugRow.tsx b/webui/src/pages/list/BugRow.tsx new file mode 100644 index 00000000..9c1883a3 --- /dev/null +++ b/webui/src/pages/list/BugRow.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +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 Date from 'src/components/Date'; +import Label from 'src/components/Label'; +import { Status } from 'src/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/pages/list/Filter.tsx b/webui/src/pages/list/Filter.tsx new file mode 100644 index 00000000..0635e7f0 --- /dev/null +++ b/webui/src/pages/list/Filter.tsx @@ -0,0 +1,190 @@ +import clsx from 'clsx'; +import { LocationDescriptor } from 'history'; +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; + +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'; + +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; // the target on click + 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/pages/list/FilterToolbar.graphql b/webui/src/pages/list/FilterToolbar.graphql new file mode 100644 index 00000000..cd103f44 --- /dev/null +++ b/webui/src/pages/list/FilterToolbar.graphql @@ -0,0 +1,7 @@ +query BugCount($query: String) { + repository { + bugs: allBugs(query: $query) { + totalCount + } + } +} diff --git a/webui/src/pages/list/FilterToolbar.tsx b/webui/src/pages/list/FilterToolbar.tsx new file mode 100644 index 00000000..c568a9dd --- /dev/null +++ b/webui/src/pages/list/FilterToolbar.tsx @@ -0,0 +1,135 @@ +import { pipe } from '@arrows/composition'; +import { LocationDescriptor } from 'history'; +import React from 'react'; + +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 { + 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; // the query used as a source to count the number of element + 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 toggleParam = (key: string, value: string) => ( + params: Query + ): Query => ({ + ...params, + [key]: params[key] && params[key].includes(value) ? [] : [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(toggleParam('status', 'open'), loc)(params)} + icon={ErrorOutline} + > + open + </CountingFilter> + <CountingFilter + active={hasValue('status', 'closed')} + query={pipe( + replaceParam('status', 'closed'), + clearParam('sort'), + stringify + )(params)} + to={pipe(toggleParam('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/pages/list/List.tsx b/webui/src/pages/list/List.tsx new file mode 100644 index 00000000..c1cae122 --- /dev/null +++ b/webui/src/pages/list/List.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import Table from '@material-ui/core/Table/Table'; +import TableBody from '@material-ui/core/TableBody/TableBody'; + +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/pages/list/ListQuery.graphql b/webui/src/pages/list/ListQuery.graphql new file mode 100644 index 00000000..ded60c8a --- /dev/null +++ b/webui/src/pages/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/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx new file mode 100644 index 00000000..2d8c698a --- /dev/null +++ b/webui/src/pages/list/ListQuery.tsx @@ -0,0 +1,315 @@ +import { ApolloError } from 'apollo-boost'; +import React, { useState, useEffect, useRef } from 'react'; +import { useLocation, useHistory, Link } from 'react-router-dom'; + +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 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.has('q') ? params.get('q') || '' : 'status:open'; + + 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; diff --git a/webui/src/pages/list/index.ts b/webui/src/pages/list/index.ts new file mode 100644 index 00000000..8a91ce70 --- /dev/null +++ b/webui/src/pages/list/index.ts @@ -0,0 +1 @@ +export { default } from './ListQuery'; |