aboutsummaryrefslogtreecommitdiffstats
path: root/webui/src/pages
diff options
context:
space:
mode:
authorQuentin Gliech <quentingliech@gmail.com>2020-02-13 20:00:03 +0100
committerQuentin Gliech <quentingliech@gmail.com>2020-02-13 20:00:03 +0100
commitce6f6a984b374b189141116433ced80dfa0c2aae (patch)
treee6487b9b480e6b18767ae310b702b57e5cbef000 /webui/src/pages
parent8b85780d76ad45675582f4478eedb026b7ac25e1 (diff)
downloadgit-bug-ce6f6a984b374b189141116433ced80dfa0c2aae.tar.gz
webui: move pages components
Diffstat (limited to 'webui/src/pages')
-rw-r--r--webui/src/pages/bug/Bug.graphql13
-rw-r--r--webui/src/pages/bug/Bug.tsx98
-rw-r--r--webui/src/pages/bug/BugQuery.graphql9
-rw-r--r--webui/src/pages/bug/BugQuery.tsx23
-rw-r--r--webui/src/pages/bug/CommentForm.graphql5
-rw-r--r--webui/src/pages/bug/CommentForm.tsx146
-rw-r--r--webui/src/pages/bug/LabelChange.tsx50
-rw-r--r--webui/src/pages/bug/LabelChangeFragment.graphql12
-rw-r--r--webui/src/pages/bug/Message.tsx79
-rw-r--r--webui/src/pages/bug/MessageCommentFragment.graphql8
-rw-r--r--webui/src/pages/bug/MessageCreateFragment.graphql8
-rw-r--r--webui/src/pages/bug/SetStatus.tsx32
-rw-r--r--webui/src/pages/bug/SetStatusFragment.graphql7
-rw-r--r--webui/src/pages/bug/SetTitle.tsx38
-rw-r--r--webui/src/pages/bug/SetTitleFragment.graphql8
-rw-r--r--webui/src/pages/bug/Timeline.tsx49
-rw-r--r--webui/src/pages/bug/TimelineQuery.graphql39
-rw-r--r--webui/src/pages/bug/TimelineQuery.tsx31
-rw-r--r--webui/src/pages/bug/index.tsx1
-rw-r--r--webui/src/pages/list/BugRow.graphql13
-rw-r--r--webui/src/pages/list/BugRow.tsx113
-rw-r--r--webui/src/pages/list/Filter.tsx190
-rw-r--r--webui/src/pages/list/FilterToolbar.graphql7
-rw-r--r--webui/src/pages/list/FilterToolbar.tsx129
-rw-r--r--webui/src/pages/list/List.tsx22
-rw-r--r--webui/src/pages/list/ListQuery.graphql37
-rw-r--r--webui/src/pages/list/ListQuery.tsx315
-rw-r--r--webui/src/pages/list/index.ts1
28 files changed, 1483 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..998c9528
--- /dev/null
+++ b/webui/src/pages/bug/Bug.tsx
@@ -0,0 +1,98 @@
+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: 800,
+ margin: 'auto',
+ marginTop: theme.spacing(4),
+ },
+ header: {
+ marginLeft: theme.spacing(1) + 40,
+ },
+ title: {
+ ...theme.typography.h5,
+ },
+ id: {
+ ...theme.typography.subtitle1,
+ marginLeft: theme.spacing(1),
+ },
+ container: {
+ display: 'flex',
+ marginBottom: theme.spacing(1),
+ },
+ timeline: {
+ flex: 1,
+ marginTop: theme.spacing(2),
+ marginRight: theme.spacing(2),
+ minWidth: 0,
+ },
+ sidebar: {
+ marginTop: theme.spacing(2),
+ flex: '0 0 200px',
+ },
+ labelList: {
+ listStyle: 'none',
+ padding: 0,
+ margin: 0,
+ },
+ label: {
+ marginTop: theme.spacing(1),
+ marginBottom: theme.spacing(1),
+ '& > *': {
+ display: 'block',
+ },
+ },
+}));
+
+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>
+ <div className={classes.sidebar}>
+ <Typography variant={'subtitle1'}>Labels</Typography>
+ <ul className={classes.labelList}>
+ {bug.labels.map(l => (
+ <li className={classes.label} key={l.name}>
+ <Label label={l} key={l.name} />
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+
+ <CommentForm bugId={bug.id} />
+ </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..764947ee
--- /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.body1,
+ 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..ebb42f6b
--- /dev/null
+++ b/webui/src/pages/bug/Message.tsx
@@ -0,0 +1,79 @@
+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',
+ },
+ 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..251abf69
--- /dev/null
+++ b/webui/src/pages/bug/SetStatus.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
+
+import { SetStatusFragment } from './SetStatusFragment.generated';
+
+const useStyles = makeStyles(theme => ({
+ main: {
+ ...theme.typography.body1,
+ marginLeft: theme.spacing(1) + 40,
+ },
+}));
+
+type Props = {
+ op: SetStatusFragment;
+};
+
+function SetStatus({ op }: Props) {
+ const classes = useStyles();
+ return (
+ <div className={classes.main}>
+ <Author author={op.author} bold />
+ <span> {op.status.toLowerCase()} 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..304fd2e2
--- /dev/null
+++ b/webui/src/pages/bug/SetTitle.tsx
@@ -0,0 +1,38 @@
+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.body1,
+ marginLeft: theme.spacing(1) + 40,
+ },
+ bold: {
+ fontWeight: 'bold',
+ },
+}));
+
+type Props = {
+ op: SetTitleFragment;
+};
+
+function SetTitle({ op }: Props) {
+ const classes = useStyles();
+ return (
+ <div className={classes.main}>
+ <Author author={op.author} className={classes.bold} />
+ <span> changed the title from </span>
+ <span className={classes.bold}>{op.was}</span>
+ <span> to </span>
+ <span className={classes.bold}>{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..829877ef
--- /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..1a3cdd6b
--- /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;
+ 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..825a9dee
--- /dev/null
+++ b/webui/src/pages/list/FilterToolbar.tsx
@@ -0,0 +1,129 @@
+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;
+ 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/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..6858b6c6
--- /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.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;
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';