aboutsummaryrefslogtreecommitdiffstats
path: root/webui/src
diff options
context:
space:
mode:
Diffstat (limited to 'webui/src')
-rw-r--r--webui/src/.gitignore5
-rw-r--r--webui/src/App.tsx (renamed from webui/src/App.js)15
-rw-r--r--webui/src/Author.graphql8
-rw-r--r--webui/src/Author.tsx (renamed from webui/src/Author.js)25
-rw-r--r--webui/src/Content.tsx (renamed from webui/src/Content.js)12
-rw-r--r--webui/src/CurrentIdentity.graphql8
-rw-r--r--webui/src/CurrentIdentity.js45
-rw-r--r--webui/src/CurrentIdentity.tsx30
-rw-r--r--webui/src/Date.tsx (renamed from webui/src/Date.js)5
-rw-r--r--webui/src/Label.graphql8
-rw-r--r--webui/src/Label.tsx (renamed from webui/src/Label.js)33
-rw-r--r--webui/src/__tests__/query.ts (renamed from webui/src/__tests__/query.js)0
-rw-r--r--webui/src/bug/Bug.graphql14
-rw-r--r--webui/src/bug/Bug.tsx (renamed from webui/src/bug/Bug.js)30
-rw-r--r--webui/src/bug/BugQuery.graphql9
-rw-r--r--webui/src/bug/BugQuery.js30
-rw-r--r--webui/src/bug/BugQuery.tsx22
-rw-r--r--webui/src/bug/LabelChange.tsx (renamed from webui/src/bug/LabelChange.js)30
-rw-r--r--webui/src/bug/LabelChangeFragment.graphql13
-rw-r--r--webui/src/bug/Message.tsx (renamed from webui/src/bug/Message.js)41
-rw-r--r--webui/src/bug/MessageCommentFragment.graphql8
-rw-r--r--webui/src/bug/MessageCreateFragment.graphql8
-rw-r--r--webui/src/bug/SetStatus.tsx (renamed from webui/src/bug/SetStatus.js)24
-rw-r--r--webui/src/bug/SetStatusFragment.graphql7
-rw-r--r--webui/src/bug/SetTitle.tsx (renamed from webui/src/bug/SetTitle.js)25
-rw-r--r--webui/src/bug/SetTitleFragment.graphql8
-rw-r--r--webui/src/bug/Timeline.js43
-rw-r--r--webui/src/bug/Timeline.tsx48
-rw-r--r--webui/src/bug/TimelineQuery.graphql39
-rw-r--r--webui/src/bug/TimelineQuery.js53
-rw-r--r--webui/src/bug/TimelineQuery.tsx30
-rw-r--r--webui/src/index.tsx (renamed from webui/src/index.js)4
-rw-r--r--webui/src/list/BugRow.graphql14
-rw-r--r--webui/src/list/BugRow.tsx (renamed from webui/src/list/BugRow.js)49
-rw-r--r--webui/src/list/Filter.tsx (renamed from webui/src/list/Filter.js)103
-rw-r--r--webui/src/list/FilterToolbar.graphql7
-rw-r--r--webui/src/list/FilterToolbar.tsx (renamed from webui/src/list/FilterToolbar.js)85
-rw-r--r--webui/src/list/List.tsx (renamed from webui/src/list/List.js)5
-rw-r--r--webui/src/list/ListQuery.graphql37
-rw-r--r--webui/src/list/ListQuery.tsx (renamed from webui/src/list/ListQuery.js)181
-rw-r--r--webui/src/react-app-env.d.ts1
-rw-r--r--webui/src/tag/ImageTag.tsx (renamed from webui/src/tag/ImageTag.js)7
-rw-r--r--webui/src/tag/PreTag.tsx (renamed from webui/src/tag/PreTag.js)4
43 files changed, 643 insertions, 530 deletions
diff --git a/webui/src/.gitignore b/webui/src/.gitignore
index 5134e469..2ef0dba1 100644
--- a/webui/src/.gitignore
+++ b/webui/src/.gitignore
@@ -1 +1,4 @@
-fragmentTypes.js
+fragmentTypes.ts
+gqlTypes.ts
+*.generated.*
+schema.json
diff --git a/webui/src/App.js b/webui/src/App.tsx
index b9c57327..6f66a6ec 100644
--- a/webui/src/App.js
+++ b/webui/src/App.tsx
@@ -1,15 +1,18 @@
import AppBar from '@material-ui/core/AppBar';
import CssBaseline from '@material-ui/core/CssBaseline';
-import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles';
-import { makeStyles } from '@material-ui/styles';
import Toolbar from '@material-ui/core/Toolbar';
+import {
+ createMuiTheme,
+ ThemeProvider,
+ makeStyles,
+} from '@material-ui/core/styles';
import React from 'react';
import { Route, Switch } from 'react-router';
import { Link } from 'react-router-dom';
+import CurrentIdentity from './CurrentIdentity';
import BugQuery from './bug/BugQuery';
import ListQuery from './list/ListQuery';
-import CurrentIdentity from './CurrentIdentity';
const theme = createMuiTheme({
palette: {
@@ -20,7 +23,9 @@ const theme = createMuiTheme({
});
const useStyles = makeStyles(theme => ({
- offset: theme.mixins.toolbar,
+ offset: {
+ ...theme.mixins.toolbar,
+ },
filler: {
flexGrow: 1,
},
@@ -46,7 +51,7 @@ export default function App() {
<AppBar position="fixed" color="primary">
<Toolbar>
<Link to="/" className={classes.appTitle}>
- <img src="logo.svg" className={classes.logo} alt="git-bug" />
+ <img src="/logo.svg" className={classes.logo} alt="git-bug" />
git-bug
</Link>
<div className={classes.filler}></div>
diff --git a/webui/src/Author.graphql b/webui/src/Author.graphql
new file mode 100644
index 00000000..76d66b91
--- /dev/null
+++ b/webui/src/Author.graphql
@@ -0,0 +1,8 @@
+fragment authored on Authored {
+ author {
+ name
+ email
+ displayName
+ avatarUrl
+ }
+}
diff --git a/webui/src/Author.js b/webui/src/Author.tsx
index 237a7956..852cd2b7 100644
--- a/webui/src/Author.js
+++ b/webui/src/Author.tsx
@@ -1,9 +1,15 @@
-import gql from 'graphql-tag';
-import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import MAvatar from '@material-ui/core/Avatar';
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
import React from 'react';
-const Author = ({ author, ...props }) => {
+import { AuthoredFragment } from './Author.generated';
+
+type Props = AuthoredFragment & {
+ className?: string;
+ bold?: boolean;
+};
+
+const Author = ({ author, ...props }: Props) => {
if (!author.email) {
return <span {...props}>{author.displayName}</span>;
}
@@ -15,18 +21,7 @@ const Author = ({ author, ...props }) => {
);
};
-Author.fragment = gql`
- fragment authored on Authored {
- author {
- name
- email
- displayName
- avatarUrl
- }
- }
-`;
-
-export const Avatar = ({ author, ...props }) => {
+export const Avatar = ({ author, ...props }: Props) => {
if (author.avatarUrl) {
return <MAvatar src={author.avatarUrl} {...props} />;
}
diff --git a/webui/src/Content.js b/webui/src/Content.tsx
index 3a6900bc..3a7af2f8 100644
--- a/webui/src/Content.js
+++ b/webui/src/Content.tsx
@@ -1,11 +1,14 @@
-import unified from 'unified';
-import parse from 'remark-parse';
+import React from 'react';
import html from 'remark-html';
+import parse from 'remark-parse';
import remark2react from 'remark-react';
+import unified from 'unified';
+
import ImageTag from './tag/ImageTag';
import PreTag from './tag/PreTag';
-const Content = ({ markdown }) => {
+type Props = { markdown: string };
+const Content: React.FC<Props> = ({ markdown }: Props) => {
const processor = unified()
.use(parse)
.use(html)
@@ -16,7 +19,8 @@ const Content = ({ markdown }) => {
},
});
- return processor.processSync(markdown).contents;
+ const contents: React.ReactNode = processor.processSync(markdown).contents;
+ return <>{contents}</>;
};
export default Content;
diff --git a/webui/src/CurrentIdentity.graphql b/webui/src/CurrentIdentity.graphql
new file mode 100644
index 00000000..2794a40f
--- /dev/null
+++ b/webui/src/CurrentIdentity.graphql
@@ -0,0 +1,8 @@
+query CurrentIdentity {
+ repository {
+ userIdentity {
+ displayName
+ avatarUrl
+ }
+ }
+}
diff --git a/webui/src/CurrentIdentity.js b/webui/src/CurrentIdentity.js
deleted file mode 100644
index 451979fb..00000000
--- a/webui/src/CurrentIdentity.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import gql from 'graphql-tag';
-import { Query } from 'react-apollo';
-import Avatar from '@material-ui/core/Avatar';
-import { makeStyles } from '@material-ui/styles';
-
-const useStyles = makeStyles(theme => ({
- displayName: {
- marginLeft: theme.spacing(2),
- },
-}));
-
-const QUERY = gql`
- {
- defaultRepository {
- userIdentity {
- displayName
- avatarUrl
- }
- }
- }
-`;
-
-const CurrentIdentity = () => {
- const classes = useStyles();
- return (
- <Query query={QUERY}>
- {({ loading, error, data }) => {
- if (error || loading || !data.defaultRepository.userIdentity)
- return null;
- const user = data.defaultRepository.userIdentity;
- return (
- <>
- <Avatar src={user.avatarUrl}>
- {user.displayName.charAt(0).toUpperCase()}
- </Avatar>
- <div className={classes.displayName}>{user.displayName}</div>
- </>
- );
- }}
- </Query>
- );
-};
-
-export default CurrentIdentity;
diff --git a/webui/src/CurrentIdentity.tsx b/webui/src/CurrentIdentity.tsx
new file mode 100644
index 00000000..256f44c4
--- /dev/null
+++ b/webui/src/CurrentIdentity.tsx
@@ -0,0 +1,30 @@
+import Avatar from '@material-ui/core/Avatar';
+import { makeStyles } from '@material-ui/core/styles';
+import React from 'react';
+
+import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+
+const useStyles = makeStyles(theme => ({
+ displayName: {
+ marginLeft: theme.spacing(2),
+ },
+}));
+
+const CurrentIdentity = () => {
+ const classes = useStyles();
+ const { loading, error, data } = useCurrentIdentityQuery();
+
+ if (error || loading || !data?.repository?.userIdentity) return null;
+
+ const user = data.repository.userIdentity;
+ return (
+ <>
+ <Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
+ {user.displayName.charAt(0).toUpperCase()}
+ </Avatar>
+ <div className={classes.displayName}>{user.displayName}</div>
+ </>
+ );
+};
+
+export default CurrentIdentity;
diff --git a/webui/src/Date.js b/webui/src/Date.tsx
index 46741924..9380d2fc 100644
--- a/webui/src/Date.js
+++ b/webui/src/Date.tsx
@@ -1,8 +1,9 @@
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
-import * as moment from 'moment';
+import moment from 'moment';
import React from 'react';
-const Date = ({ date }) => (
+type Props = { date: string };
+const Date = ({ date }: Props) => (
<Tooltip title={moment(date).format('MMMM D, YYYY, h:mm a')}>
<span> {moment(date).fromNow()} </span>
</Tooltip>
diff --git a/webui/src/Label.graphql b/webui/src/Label.graphql
new file mode 100644
index 00000000..22522ada
--- /dev/null
+++ b/webui/src/Label.graphql
@@ -0,0 +1,8 @@
+fragment Label on Label {
+ name
+ color {
+ R
+ G
+ B
+ }
+}
diff --git a/webui/src/Label.js b/webui/src/Label.tsx
index e5b00b12..a33b4c2c 100644
--- a/webui/src/Label.js
+++ b/webui/src/Label.tsx
@@ -1,25 +1,28 @@
-import React from 'react';
-import gql from 'graphql-tag';
-import { makeStyles } from '@material-ui/styles';
+import { common } from '@material-ui/core/colors';
+import { makeStyles } from '@material-ui/core/styles';
import {
getContrastRatio,
darken,
} from '@material-ui/core/styles/colorManipulator';
-import { common } from '@material-ui/core/colors';
+import React from 'react';
+
+import { LabelFragment } from './Label.generated';
+import { Color } from './gqlTypes';
// Minimum contrast between the background and the text color
const contrastThreshold = 2.5;
// Guess the text color based on the background color
-const getTextColor = background =>
+const getTextColor = (background: string) =>
getContrastRatio(background, common.white) >= contrastThreshold
? common.white // White on dark backgrounds
: common.black; // And black on light ones
-const _rgb = color => 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
+const _rgb = (color: Color) =>
+ 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
// Create a style object from the label RGB colors
-const createStyle = color => ({
+const createStyle = (color: Color) => ({
backgroundColor: _rgb(color),
color: getTextColor(_rgb(color)),
borderBottomColor: darken(_rgb(color), 0.2),
@@ -30,7 +33,7 @@ const useStyles = makeStyles(theme => ({
...theme.typography.body1,
padding: '1px 6px 0.5px',
fontSize: '0.9em',
- fontWeight: '500',
+ fontWeight: 500,
margin: '0.05em 1px calc(-1.5px + 0.05em)',
borderRadius: '3px',
display: 'inline-block',
@@ -39,7 +42,8 @@ const useStyles = makeStyles(theme => ({
},
}));
-function Label({ label }) {
+type Props = { label: LabelFragment };
+function Label({ label }: Props) {
const classes = useStyles();
return (
<span className={classes.label} style={createStyle(label.color)}>
@@ -48,15 +52,4 @@ function Label({ label }) {
);
}
-Label.fragment = gql`
- fragment Label on Label {
- name
- color {
- R
- G
- B
- }
- }
-`;
-
export default Label;
diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.ts
index 5f4b58eb..5f4b58eb 100644
--- a/webui/src/__tests__/query.js
+++ b/webui/src/__tests__/query.ts
diff --git a/webui/src/bug/Bug.graphql b/webui/src/bug/Bug.graphql
new file mode 100644
index 00000000..112024aa
--- /dev/null
+++ b/webui/src/bug/Bug.graphql
@@ -0,0 +1,14 @@
+#import "../Label.graphql"
+#import "../Author.graphql"
+
+fragment Bug on Bug {
+ id
+ humanId
+ status
+ title
+ labels {
+ ...Label
+ }
+ createdAt
+ ...authored
+}
diff --git a/webui/src/bug/Bug.js b/webui/src/bug/Bug.tsx
index 5a159f0f..f4029a5f 100644
--- a/webui/src/bug/Bug.js
+++ b/webui/src/bug/Bug.tsx
@@ -1,12 +1,14 @@
-import { makeStyles } from '@material-ui/styles';
import Typography from '@material-ui/core/Typography/Typography';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
+
import Author from '../Author';
import Date from '../Date';
-import TimelineQuery from './TimelineQuery';
import Label from '../Label';
+import { BugFragment } from './Bug.generated';
+import TimelineQuery from './TimelineQuery';
+
const useStyles = makeStyles(theme => ({
main: {
maxWidth: 800,
@@ -51,7 +53,11 @@ const useStyles = makeStyles(theme => ({
},
}));
-function Bug({ bug }) {
+type Props = {
+ bug: BugFragment;
+};
+
+function Bug({ bug }: Props) {
const classes = useStyles();
return (
<main className={classes.main}>
@@ -85,20 +91,4 @@ function Bug({ bug }) {
);
}
-Bug.fragment = gql`
- fragment Bug on Bug {
- id
- humanId
- status
- title
- labels {
- ...Label
- }
- createdAt
- ...authored
- }
- ${Label.fragment}
- ${Author.fragment}
-`;
-
export default Bug;
diff --git a/webui/src/bug/BugQuery.graphql b/webui/src/bug/BugQuery.graphql
new file mode 100644
index 00000000..cdc4723f
--- /dev/null
+++ b/webui/src/bug/BugQuery.graphql
@@ -0,0 +1,9 @@
+#import "./Bug.graphql"
+
+query GetBug($id: String!) {
+ repository {
+ bug(prefix: $id) {
+ ...Bug
+ }
+ }
+}
diff --git a/webui/src/bug/BugQuery.js b/webui/src/bug/BugQuery.js
deleted file mode 100644
index dbf24c31..00000000
--- a/webui/src/bug/BugQuery.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import CircularProgress from '@material-ui/core/CircularProgress';
-import gql from 'graphql-tag';
-import React from 'react';
-import { Query } from 'react-apollo';
-
-import Bug from './Bug';
-
-const QUERY = gql`
- query GetBug($id: String!) {
- defaultRepository {
- bug(prefix: $id) {
- ...Bug
- }
- }
- }
-
- ${Bug.fragment}
-`;
-
-const BugQuery = ({ match }) => (
- <Query query={QUERY} variables={{ id: match.params.id }}>
- {({ loading, error, data }) => {
- if (loading) return <CircularProgress />;
- if (error) return <p>Error: {error}</p>;
- return <Bug bug={data.defaultRepository.bug} />;
- }}
- </Query>
-);
-
-export default BugQuery;
diff --git a/webui/src/bug/BugQuery.tsx b/webui/src/bug/BugQuery.tsx
new file mode 100644
index 00000000..2ecf718c
--- /dev/null
+++ b/webui/src/bug/BugQuery.tsx
@@ -0,0 +1,22 @@
+import CircularProgress from '@material-ui/core/CircularProgress';
+import React from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+
+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/bug/LabelChange.js b/webui/src/bug/LabelChange.tsx
index 4773e7eb..572579bd 100644
--- a/webui/src/bug/LabelChange.js
+++ b/webui/src/bug/LabelChange.tsx
@@ -1,10 +1,12 @@
-import { makeStyles } from '@material-ui/styles';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
+
import Author from '../Author';
import Date from '../Date';
import Label from '../Label';
+import { LabelChangeFragment } from './LabelChangeFragment.generated';
+
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@@ -15,7 +17,11 @@ const useStyles = makeStyles(theme => ({
},
}));
-function LabelChange({ op }) {
+type Props = {
+ op: LabelChangeFragment;
+};
+
+function LabelChange({ op }: Props) {
const { added, removed } = op;
const classes = useStyles();
return (
@@ -40,22 +46,4 @@ function LabelChange({ op }) {
);
}
-LabelChange.fragment = gql`
- fragment LabelChange on TimelineItem {
- ... on LabelChangeTimelineItem {
- date
- ...authored
- added {
- ...Label
- }
- removed {
- ...Label
- }
- }
- }
-
- ${Label.fragment}
- ${Author.fragment}
-`;
-
export default LabelChange;
diff --git a/webui/src/bug/LabelChangeFragment.graphql b/webui/src/bug/LabelChangeFragment.graphql
new file mode 100644
index 00000000..631de70c
--- /dev/null
+++ b/webui/src/bug/LabelChangeFragment.graphql
@@ -0,0 +1,13 @@
+#import "../Author.graphql"
+#import "../Label.graphql"
+
+fragment LabelChange on LabelChangeTimelineItem {
+ date
+ ...authored
+ added {
+ ...Label
+ }
+ removed {
+ ...Label
+ }
+}
diff --git a/webui/src/bug/Message.js b/webui/src/bug/Message.tsx
index 06c12815..c8d0710d 100644
--- a/webui/src/bug/Message.js
+++ b/webui/src/bug/Message.tsx
@@ -1,11 +1,14 @@
-import { makeStyles } from '@material-ui/styles';
import Paper from '@material-ui/core/Paper';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
+
import Author from '../Author';
import { Avatar } from '../Author';
-import Date from '../Date';
import Content from '../Content';
+import Date from '../Date';
+
+import { AddCommentFragment } from './MessageCommentFragment.generated';
+import { CreateFragment } from './MessageCreateFragment.generated';
const useStyles = makeStyles(theme => ({
author: {
@@ -47,7 +50,11 @@ const useStyles = makeStyles(theme => ({
},
}));
-function Message({ op }) {
+type Props = {
+ op: AddCommentFragment | CreateFragment;
+};
+
+function Message({ op }: Props) {
const classes = useStyles();
return (
<article className={classes.container}>
@@ -69,30 +76,4 @@ function Message({ op }) {
);
}
-Message.createFragment = gql`
- fragment Create on TimelineItem {
- ... on CreateTimelineItem {
- createdAt
- ...authored
- edited
- message
- }
- }
-
- ${Author.fragment}
-`;
-
-Message.commentFragment = gql`
- fragment AddComment on TimelineItem {
- ... on AddCommentTimelineItem {
- createdAt
- ...authored
- edited
- message
- }
- }
-
- ${Author.fragment}
-`;
-
export default Message;
diff --git a/webui/src/bug/MessageCommentFragment.graphql b/webui/src/bug/MessageCommentFragment.graphql
new file mode 100644
index 00000000..38d626d0
--- /dev/null
+++ b/webui/src/bug/MessageCommentFragment.graphql
@@ -0,0 +1,8 @@
+#import "../Author.graphql"
+
+fragment AddComment on AddCommentTimelineItem {
+ createdAt
+ ...authored
+ edited
+ message
+}
diff --git a/webui/src/bug/MessageCreateFragment.graphql b/webui/src/bug/MessageCreateFragment.graphql
new file mode 100644
index 00000000..08477470
--- /dev/null
+++ b/webui/src/bug/MessageCreateFragment.graphql
@@ -0,0 +1,8 @@
+#import "../Author.graphql"
+
+fragment Create on CreateTimelineItem {
+ createdAt
+ ...authored
+ edited
+ message
+}
diff --git a/webui/src/bug/SetStatus.js b/webui/src/bug/SetStatus.tsx
index 070bbb8f..3e1a7989 100644
--- a/webui/src/bug/SetStatus.js
+++ b/webui/src/bug/SetStatus.tsx
@@ -1,9 +1,11 @@
-import { makeStyles } from '@material-ui/styles';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
+
import Author from '../Author';
import Date from '../Date';
+import { SetStatusFragment } from './SetStatusFragment.generated';
+
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@@ -11,7 +13,11 @@ const useStyles = makeStyles(theme => ({
},
}));
-function SetStatus({ op }) {
+type Props = {
+ op: SetStatusFragment;
+};
+
+function SetStatus({ op }: Props) {
const classes = useStyles();
return (
<div className={classes.main}>
@@ -22,16 +28,4 @@ function SetStatus({ op }) {
);
}
-SetStatus.fragment = gql`
- fragment SetStatus on TimelineItem {
- ... on SetStatusTimelineItem {
- date
- ...authored
- status
- }
- }
-
- ${Author.fragment}
-`;
-
export default SetStatus;
diff --git a/webui/src/bug/SetStatusFragment.graphql b/webui/src/bug/SetStatusFragment.graphql
new file mode 100644
index 00000000..0fdea01b
--- /dev/null
+++ b/webui/src/bug/SetStatusFragment.graphql
@@ -0,0 +1,7 @@
+#import "../Author.graphql"
+
+fragment SetStatus on SetStatusTimelineItem {
+ date
+ ...authored
+ status
+}
diff --git a/webui/src/bug/SetTitle.js b/webui/src/bug/SetTitle.tsx
index e4c30a8d..0b088e0b 100644
--- a/webui/src/bug/SetTitle.js
+++ b/webui/src/bug/SetTitle.tsx
@@ -1,9 +1,11 @@
-import { makeStyles } from '@material-ui/styles';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
+
import Author from '../Author';
import Date from '../Date';
+import { SetTitleFragment } from './SetTitleFragment.generated';
+
const useStyles = makeStyles(theme => ({
main: {
...theme.typography.body1,
@@ -14,7 +16,11 @@ const useStyles = makeStyles(theme => ({
},
}));
-function SetTitle({ op }) {
+type Props = {
+ op: SetTitleFragment;
+};
+
+function SetTitle({ op }: Props) {
const classes = useStyles();
return (
<div className={classes.main}>
@@ -28,17 +34,4 @@ function SetTitle({ op }) {
);
}
-SetTitle.fragment = gql`
- fragment SetTitle on TimelineItem {
- ... on SetTitleTimelineItem {
- date
- ...authored
- title
- was
- }
- }
-
- ${Author.fragment}
-`;
-
export default SetTitle;
diff --git a/webui/src/bug/SetTitleFragment.graphql b/webui/src/bug/SetTitleFragment.graphql
new file mode 100644
index 00000000..432c4449
--- /dev/null
+++ b/webui/src/bug/SetTitleFragment.graphql
@@ -0,0 +1,8 @@
+#import "../Author.graphql"
+
+fragment SetTitle on SetTitleTimelineItem {
+ date
+ ...authored
+ title
+ was
+}
diff --git a/webui/src/bug/Timeline.js b/webui/src/bug/Timeline.js
deleted file mode 100644
index 7d1946f2..00000000
--- a/webui/src/bug/Timeline.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { makeStyles } from '@material-ui/styles';
-import React from 'react';
-import LabelChange from './LabelChange';
-import Message from './Message';
-import SetStatus from './SetStatus';
-import SetTitle from './SetTitle';
-
-const useStyles = makeStyles(theme => ({
- main: {
- '& > *:not(:last-child)': {
- marginBottom: theme.spacing(2),
- },
- },
-}));
-
-const componentMap = {
- CreateTimelineItem: Message,
- AddCommentTimelineItem: Message,
- LabelChangeTimelineItem: LabelChange,
- SetTitleTimelineItem: SetTitle,
- SetStatusTimelineItem: SetStatus,
-};
-
-function Timeline({ ops }) {
- const classes = useStyles();
-
- return (
- <div className={classes.main}>
- {ops.map((op, index) => {
- const Component = componentMap[op.__typename];
-
- if (!Component) {
- console.warn('unsupported operation type ' + op.__typename);
- return null;
- }
-
- return <Component key={index} op={op} />;
- })}
- </div>
- );
-}
-
-export default Timeline;
diff --git a/webui/src/bug/Timeline.tsx b/webui/src/bug/Timeline.tsx
new file mode 100644
index 00000000..ba0f9fc7
--- /dev/null
+++ b/webui/src/bug/Timeline.tsx
@@ -0,0 +1,48 @@
+import { makeStyles } from '@material-ui/core/styles';
+import React from 'react';
+
+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/bug/TimelineQuery.graphql b/webui/src/bug/TimelineQuery.graphql
new file mode 100644
index 00000000..6d78ab7f
--- /dev/null
+++ b/webui/src/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/bug/TimelineQuery.js b/webui/src/bug/TimelineQuery.js
deleted file mode 100644
index ebb20f9d..00000000
--- a/webui/src/bug/TimelineQuery.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import CircularProgress from '@material-ui/core/CircularProgress';
-import gql from 'graphql-tag';
-import React from 'react';
-import { Query } from 'react-apollo';
-import LabelChange from './LabelChange';
-import SetStatus from './SetStatus';
-import SetTitle from './SetTitle';
-import Timeline from './Timeline';
-import Message from './Message';
-
-const QUERY = gql`
- query($id: String!, $first: Int = 10, $after: String) {
- defaultRepository {
- bug(prefix: $id) {
- timeline(first: $first, after: $after) {
- nodes {
- ...LabelChange
- ...SetStatus
- ...SetTitle
- ...AddComment
- ...Create
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- }
- ${Message.createFragment}
- ${Message.commentFragment}
- ${LabelChange.fragment}
- ${SetTitle.fragment}
- ${SetStatus.fragment}
-`;
-
-const TimelineQuery = ({ id }) => (
- <Query query={QUERY} variables={{ id, first: 100 }}>
- {({ loading, error, data, fetchMore }) => {
- if (loading) return <CircularProgress />;
- if (error) return <p>Error: {error}</p>;
- return (
- <Timeline
- ops={data.defaultRepository.bug.timeline.nodes}
- fetchMore={fetchMore}
- />
- );
- }}
- </Query>
-);
-
-export default TimelineQuery;
diff --git a/webui/src/bug/TimelineQuery.tsx b/webui/src/bug/TimelineQuery.tsx
new file mode 100644
index 00000000..9c4cf183
--- /dev/null
+++ b/webui/src/bug/TimelineQuery.tsx
@@ -0,0 +1,30 @@
+import CircularProgress from '@material-ui/core/CircularProgress';
+import React from 'react';
+
+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/index.js b/webui/src/index.tsx
index 6f838c69..c64daf0c 100644
--- a/webui/src/index.js
+++ b/webui/src/index.tsx
@@ -1,5 +1,5 @@
-import ThemeProvider from '@material-ui/styles/ThemeProvider';
import { createMuiTheme } from '@material-ui/core/styles';
+import ThemeProvider from '@material-ui/styles/ThemeProvider';
import ApolloClient from 'apollo-boost';
import {
IntrospectionFragmentMatcher,
@@ -10,8 +10,8 @@ import { ApolloProvider } from 'react-apollo';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
-import introspectionQueryResultData from './fragmentTypes';
import App from './App';
+import introspectionQueryResultData from './fragmentTypes';
const theme = createMuiTheme();
diff --git a/webui/src/list/BugRow.graphql b/webui/src/list/BugRow.graphql
new file mode 100644
index 00000000..3f9a1ef6
--- /dev/null
+++ b/webui/src/list/BugRow.graphql
@@ -0,0 +1,14 @@
+#import "../Author.graphql"
+#import "../Label.graphql"
+
+fragment BugRow on Bug {
+ id
+ humanId
+ title
+ status
+ createdAt
+ labels {
+ ...Label
+ }
+ ...authored
+}
diff --git a/webui/src/list/BugRow.js b/webui/src/list/BugRow.tsx
index add5c12f..f94538a7 100644
--- a/webui/src/list/BugRow.js
+++ b/webui/src/list/BugRow.tsx
@@ -1,36 +1,43 @@
-import { makeStyles } from '@material-ui/styles';
import TableCell from '@material-ui/core/TableCell/TableCell';
import TableRow from '@material-ui/core/TableRow/TableRow';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
-import ErrorOutline from '@material-ui/icons/ErrorOutline';
+import { makeStyles } from '@material-ui/core/styles';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
-import gql from 'graphql-tag';
+import ErrorOutline from '@material-ui/icons/ErrorOutline';
import React from 'react';
import { Link } from 'react-router-dom';
+
import Date from '../Date';
import Label from '../Label';
-import Author from '../Author';
+import { Status } from '../gqlTypes';
-const Open = ({ className }) => (
+import { BugRowFragment } from './BugRow.generated';
+
+type OpenClosedProps = { className: string };
+const Open = ({ className }: OpenClosedProps) => (
<Tooltip title="Open">
<ErrorOutline htmlColor="#28a745" className={className} />
</Tooltip>
);
-const Closed = ({ className }) => (
+const Closed = ({ className }: OpenClosedProps) => (
<Tooltip title="Closed">
<CheckCircleOutline htmlColor="#cb2431" className={className} />
</Tooltip>
);
-const Status = ({ status, className }) => {
+type StatusProps = { className: string; status: Status };
+const BugStatus: React.FC<StatusProps> = ({
+ status,
+ className,
+}: StatusProps) => {
switch (status) {
case 'OPEN':
return <Open className={className} />;
case 'CLOSED':
return <Closed className={className} />;
default:
- return 'unknown status ' + status;
+ return <p>{'unknown status ' + status}</p>;
}
};
@@ -57,7 +64,6 @@ const useStyles = makeStyles(theme => ({
fontWeight: 500,
},
details: {
- ...theme.typography.textSecondary,
lineHeight: '1.5rem',
color: theme.palette.text.secondary,
},
@@ -69,12 +75,16 @@ const useStyles = makeStyles(theme => ({
},
}));
-function BugRow({ bug }) {
+type Props = {
+ bug: BugRowFragment;
+};
+
+function BugRow({ bug }: Props) {
const classes = useStyles();
return (
<TableRow hover>
<TableCell className={classes.cell}>
- <Status status={bug.status} className={classes.status} />
+ <BugStatus status={bug.status} className={classes.status} />
<div className={classes.expand}>
<Link to={'bug/' + bug.humanId}>
<div className={classes.expand}>
@@ -99,21 +109,4 @@ function BugRow({ bug }) {
);
}
-BugRow.fragment = gql`
- fragment BugRow on Bug {
- id
- humanId
- title
- status
- createdAt
- labels {
- ...Label
- }
- ...authored
- }
-
- ${Label.fragment}
- ${Author.fragment}
-`;
-
export default BugRow;
diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.tsx
index a6cf3633..30b52de8 100644
--- a/webui/src/list/Filter.js
+++ b/webui/src/list/Filter.tsx
@@ -1,13 +1,18 @@
-import React, { useState, useRef } from 'react';
-import { Link } from 'react-router-dom';
-import { makeStyles } from '@material-ui/styles';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
+import { SvgIconProps } from '@material-ui/core/SvgIcon';
+import { makeStyles } from '@material-ui/core/styles';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
+import clsx from 'clsx';
+import { LocationDescriptor } from 'history';
+import React, { useState, useRef } from 'react';
+import { Link } from 'react-router-dom';
-function parse(query) {
+export type Query = { [key: string]: Array<string> };
+
+function parse(query: string): Query {
// TODO: extract the rest of the query?
- const params = {};
+ const params: Query = {};
// TODO: support escaping without quotes
const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g;
@@ -29,7 +34,7 @@ function parse(query) {
return params;
}
-function quote(value) {
+function quote(value: string): string {
const hasSingle = value.includes("'");
const hasDouble = value.includes('"');
const hasSpaces = value.includes(' ');
@@ -49,19 +54,19 @@ function quote(value) {
return `"${value}"`;
}
-function stringify(params) {
- const parts = Object.entries(params).map(([key, values]) => {
+function stringify(params: Query): string {
+ const parts: string[][] = Object.entries(params).map(([key, values]) => {
return values.map(value => `${key}:${quote(value)}`);
});
- return [].concat(...parts).join(' ');
+ return new Array<string>().concat(...parts).join(' ');
}
const useStyles = makeStyles(theme => ({
element: {
...theme.typography.body2,
- color: ({ active }) => (active ? '#333' : '#444'),
+ color: '#444',
padding: theme.spacing(0, 1),
- fontWeight: ({ active }) => (active ? 600 : 400),
+ fontWeight: 400,
textDecoration: 'none',
display: 'flex',
background: 'none',
@@ -69,21 +74,51 @@ const useStyles = makeStyles(theme => ({
},
itemActive: {
fontWeight: 600,
+ color: '#333',
},
icon: {
paddingRight: theme.spacing(0.5),
},
}));
-function Dropdown({ children, dropdown, itemActive, to, ...props }) {
+type DropdownTuple = [string, string];
+
+type FilterDropdownProps = {
+ children: React.ReactNode;
+ dropdown: DropdownTuple[];
+ itemActive: (key: string) => boolean;
+ icon?: React.ComponentType<SvgIconProps>;
+ to: (key: string) => LocationDescriptor;
+} & React.ButtonHTMLAttributes<HTMLButtonElement>;
+
+function FilterDropdown({
+ children,
+ dropdown,
+ itemActive,
+ icon: Icon,
+ to,
+ ...props
+}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
- const buttonRef = useRef();
- const classes = useStyles();
+ const buttonRef = useRef<HTMLButtonElement>(null);
+ const classes = useStyles({ active: false });
+
+ const content = (
+ <>
+ {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
+ <div>{children}</div>
+ </>
+ );
return (
<>
- <button ref={buttonRef} onClick={() => setOpen(!open)} {...props}>
- {children}
+ <button
+ ref={buttonRef}
+ onClick={() => setOpen(!open)}
+ className={classes.element}
+ {...props}
+ >
+ {content}
<ArrowDropDown fontSize="small" />
</button>
<Menu
@@ -104,7 +139,7 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
<MenuItem
component={Link}
to={to(key)}
- className={itemActive(key) ? classes.itemActive : null}
+ className={itemActive(key) ? classes.itemActive : undefined}
onClick={() => setOpen(false)}
key={key}
>
@@ -116,8 +151,14 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
);
}
-function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
- const classes = useStyles({ active });
+export type FilterProps = {
+ active: boolean;
+ to: LocationDescriptor;
+ icon?: React.ComponentType<SvgIconProps>;
+ children: React.ReactNode;
+};
+function Filter({ active, to, children, icon: Icon }: FilterProps) {
+ const classes = useStyles();
const content = (
<>
@@ -126,29 +167,23 @@ function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
</>
);
- if (dropdown) {
+ if (to) {
return (
- <Dropdown
- {...props}
+ <Link
to={to}
- dropdown={dropdown}
- className={classes.element}
+ className={clsx(classes.element, active && classes.itemActive)}
>
{content}
- </Dropdown>
- );
- }
-
- if (to) {
- return (
- <Link to={to} {...props} className={classes.element}>
- {content}
</Link>
);
}
- return <div className={classes.element}>{content}</div>;
+ return (
+ <div className={clsx(classes.element, active && classes.itemActive)}>
+ {content}
+ </div>
+ );
}
export default Filter;
-export { parse, stringify, quote };
+export { parse, stringify, quote, FilterDropdown, Filter };
diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql
new file mode 100644
index 00000000..cd103f44
--- /dev/null
+++ b/webui/src/list/FilterToolbar.graphql
@@ -0,0 +1,7 @@
+query BugCount($query: String) {
+ repository {
+ bugs: allBugs(query: $query) {
+ totalCount
+ }
+ }
+}
diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.tsx
index 4d0b52b1..b95b10bc 100644
--- a/webui/src/list/FilterToolbar.js
+++ b/webui/src/list/FilterToolbar.tsx
@@ -1,16 +1,20 @@
-import { makeStyles } from '@material-ui/styles';
-import { useQuery } from '@apollo/react-hooks';
-import gql from 'graphql-tag';
-import React from 'react';
+import { pipe } from '@arrows/composition';
import Toolbar from '@material-ui/core/Toolbar';
-import ErrorOutline from '@material-ui/icons/ErrorOutline';
+import { makeStyles } from '@material-ui/core/styles';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
-import Filter, { parse, stringify } from './Filter';
+import ErrorOutline from '@material-ui/icons/ErrorOutline';
+import { LocationDescriptor } from 'history';
+import React from 'react';
-// simple pipe operator
-// pipe(o, f, g, h) <=> h(g(f(o)))
-// TODO: move this out?
-const pipe = (initial, ...funcs) => funcs.reduce((acc, f) => f(acc), initial);
+import {
+ FilterDropdown,
+ FilterProps,
+ Filter,
+ parse,
+ stringify,
+ Query,
+} from './Filter';
+import { useBugCountQuery } from './FilterToolbar.generated';
const useStyles = makeStyles(theme => ({
toolbar: {
@@ -25,27 +29,21 @@ const useStyles = makeStyles(theme => ({
},
}));
-const BUG_COUNT_QUERY = gql`
- query($query: String) {
- defaultRepository {
- bugs: allBugs(query: $query) {
- totalCount
- }
- }
- }
-`;
-
// This prepends the filter text with a count
-function CountingFilter({ query, children, ...props }) {
- const { data, loading, error } = useQuery(BUG_COUNT_QUERY, {
+type CountingFilterProps = {
+ query: string;
+ children: React.ReactNode;
+} & FilterProps;
+function CountingFilter({ query, children, ...props }: CountingFilterProps) {
+ const { data, loading, error } = useBugCountQuery({
variables: { query },
});
var prefix;
if (loading) prefix = '...';
- else if (error) prefix = '???';
+ else if (error || !data?.repository) prefix = '???';
// TODO: better prefixes & error handling
- else prefix = data.defaultRepository.bugs.totalCount;
+ else prefix = data.repository.bugs.totalCount;
return (
<Filter {...props}>
@@ -54,18 +52,26 @@ function CountingFilter({ query, children, ...props }) {
);
}
-function FilterToolbar({ query, queryLocation }) {
+type Props = {
+ query: string;
+ queryLocation: (query: string) => LocationDescriptor;
+};
+function FilterToolbar({ query, queryLocation }: Props) {
const classes = useStyles();
- const params = parse(query);
+ const params: Query = parse(query);
- const hasKey = key => params[key] && params[key].length > 0;
- const hasValue = (key, value) => hasKey(key) && params[key].includes(value);
- const loc = params => pipe(params, stringify, queryLocation);
- const replaceParam = (key, value) => params => ({
+ const hasKey = (key: string): boolean =>
+ params[key] && params[key].length > 0;
+ const hasValue = (key: string, value: string): boolean =>
+ hasKey(key) && params[key].includes(value);
+ const loc = pipe(stringify, queryLocation);
+ const replaceParam = (key: string, value: string) => (
+ params: Query
+ ): Query => ({
...params,
[key]: [value],
});
- const clearParam = key => params => ({
+ const clearParam = (key: string) => (params: Query): Query => ({
...params,
[key]: [],
});
@@ -76,12 +82,11 @@ function FilterToolbar({ query, queryLocation }) {
<CountingFilter
active={hasValue('status', 'open')}
query={pipe(
- params,
replaceParam('status', 'open'),
clearParam('sort'),
stringify
- )}
- to={pipe(params, replaceParam('status', 'open'), loc)}
+ )(params)}
+ to={pipe(replaceParam('status', 'open'), loc)(params)}
icon={ErrorOutline}
>
open
@@ -89,12 +94,11 @@ function FilterToolbar({ query, queryLocation }) {
<CountingFilter
active={hasValue('status', 'closed')}
query={pipe(
- params,
replaceParam('status', 'closed'),
clearParam('sort'),
stringify
- )}
- to={pipe(params, replaceParam('status', 'closed'), loc)}
+ )(params)}
+ to={pipe(replaceParam('status', 'closed'), loc)(params)}
icon={CheckCircleOutline}
>
closed
@@ -104,7 +108,7 @@ function FilterToolbar({ query, queryLocation }) {
<Filter active={hasKey('author')}>Author</Filter>
<Filter active={hasKey('label')}>Label</Filter>
*/}
- <Filter
+ <FilterDropdown
dropdown={[
['id', 'ID'],
['creation', 'Newest'],
@@ -112,12 +116,11 @@ function FilterToolbar({ query, queryLocation }) {
['edit', 'Recently updated'],
['edit-asc', 'Least recently updated'],
]}
- active={hasKey('sort')}
itemActive={key => hasValue('sort', key)}
- to={key => pipe(params, replaceParam('sort', key), loc)}
+ to={key => pipe(replaceParam('sort', key), loc)(params)}
>
Sort
- </Filter>
+ </FilterDropdown>
</Toolbar>
);
}
diff --git a/webui/src/list/List.js b/webui/src/list/List.tsx
index 63b73545..cebd13f2 100644
--- a/webui/src/list/List.js
+++ b/webui/src/list/List.tsx
@@ -1,9 +1,12 @@
import Table from '@material-ui/core/Table/Table';
import TableBody from '@material-ui/core/TableBody/TableBody';
import React from 'react';
+
import BugRow from './BugRow';
+import { BugListFragment } from './ListQuery.generated';
-function List({ bugs }) {
+type Props = { bugs: BugListFragment };
+function List({ bugs }: Props) {
return (
<Table>
<TableBody>
diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql
new file mode 100644
index 00000000..ded60c8a
--- /dev/null
+++ b/webui/src/list/ListQuery.graphql
@@ -0,0 +1,37 @@
+#import "./BugRow.graphql"
+
+query ListBugs(
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+ $query: String
+) {
+ repository {
+ bugs: allBugs(
+ first: $first
+ last: $last
+ after: $after
+ before: $before
+ query: $query
+ ) {
+ ...BugList
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+}
+
+fragment BugList on BugConnection {
+ totalCount
+ edges {
+ cursor
+ node {
+ ...BugRow
+ }
+ }
+}
diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.tsx
index 8eeec240..84b72431 100644
--- a/webui/src/list/ListQuery.js
+++ b/webui/src/list/ListQuery.tsx
@@ -1,20 +1,21 @@
-import { fade, makeStyles } from '@material-ui/core/styles';
import IconButton from '@material-ui/core/IconButton';
+import InputBase from '@material-ui/core/InputBase';
+import Paper from '@material-ui/core/Paper';
+import { fade, makeStyles, Theme } from '@material-ui/core/styles';
+import ErrorOutline from '@material-ui/icons/ErrorOutline';
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
-import ErrorOutline from '@material-ui/icons/ErrorOutline';
-import Paper from '@material-ui/core/Paper';
-import InputBase from '@material-ui/core/InputBase';
import Skeleton from '@material-ui/lab/Skeleton';
-import gql from 'graphql-tag';
+import { ApolloError } from 'apollo-boost';
import React, { useState, useEffect, useRef } from 'react';
-import { useQuery } from '@apollo/react-hooks';
import { useLocation, useHistory, Link } from 'react-router-dom';
-import BugRow from './BugRow';
-import List from './List';
+
import FilterToolbar from './FilterToolbar';
+import List from './List';
+import { useListBugsQuery } from './ListQuery.generated';
-const useStyles = makeStyles(theme => ({
+type StylesProps = { searching?: boolean };
+const useStyles = makeStyles<Theme, StylesProps>(theme => ({
main: {
maxWidth: 800,
margin: 'auto',
@@ -46,7 +47,11 @@ const useStyles = makeStyles(theme => ({
backgroundColor: fade(theme.palette.primary.main, 0.05),
padding: theme.spacing(0, 1),
width: ({ searching }) => (searching ? '20rem' : '15rem'),
- transition: theme.transitions.create(),
+ transition: theme.transitions.create([
+ 'width',
+ 'borderColor',
+ 'backgroundColor',
+ ]),
},
searchFocused: {
borderColor: fade(theme.palette.primary.main, 0.4),
@@ -91,51 +96,21 @@ const useStyles = makeStyles(theme => ({
},
}));
-const QUERY = gql`
- query(
- $first: Int
- $last: Int
- $after: String
- $before: String
- $query: String
- ) {
- defaultRepository {
- bugs: allBugs(
- first: $first
- last: $last
- after: $after
- before: $before
- query: $query
- ) {
- totalCount
- edges {
- cursor
- node {
- ...BugRow
- }
- }
- pageInfo {
- hasNextPage
- hasPreviousPage
- startCursor
- endCursor
- }
- }
- }
- }
-
- ${BugRow.fragment}
-`;
-
-function editParams(params, callback) {
+function editParams(
+ params: URLSearchParams,
+ callback: (params: URLSearchParams) => void
+) {
const cloned = new URLSearchParams(params.toString());
callback(cloned);
return cloned;
}
// TODO: factor this out
-const Placeholder = ({ count }) => {
- const classes = useStyles();
+type PlaceholderProps = { count: number };
+const Placeholder: React.FC<PlaceholderProps> = ({
+ count,
+}: PlaceholderProps) => {
+ const classes = useStyles({});
return (
<>
{new Array(count).fill(null).map((_, i) => (
@@ -158,7 +133,7 @@ const Placeholder = ({ count }) => {
// TODO: factor this out
const NoBug = () => {
- const classes = useStyles();
+ const classes = useStyles({});
return (
<div className={classes.message}>
<ErrorOutline fontSize="large" />
@@ -167,8 +142,9 @@ const NoBug = () => {
);
};
-const Error = ({ error }) => {
- const classes = useStyles();
+type ErrorProps = { error: ApolloError };
+const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
+ const classes = useStyles({});
return (
<div className={[classes.errorBox, classes.message].join(' ')}>
<ErrorOutline fontSize="large" />
@@ -194,7 +170,7 @@ function ListQuery() {
const classes = useStyles({ searching: !!input });
// TODO is this the right way to do it?
- const lastQuery = useRef();
+ const lastQuery = useRef<string | null>(null);
useEffect(() => {
if (query !== lastQuery.current) {
setInput(query);
@@ -202,9 +178,10 @@ function ListQuery() {
lastQuery.current = query;
}, [query, input, lastQuery]);
+ const num = (param: string | null) => (param ? parseInt(param) : null);
const page = {
- first: params.get('first'),
- last: params.get('last'),
+ first: num(params.get('first')),
+ last: num(params.get('last')),
after: params.get('after'),
before: params.get('before'),
};
@@ -214,9 +191,9 @@ function ListQuery() {
page.first = 10;
}
- const perPage = page.first || page.last;
+ const perPage = (page.first || page.last || 10).toString();
- const { loading, error, data } = useQuery(QUERY, {
+ const { loading, error, data } = useListBugsQuery({
variables: {
...page,
query,
@@ -225,34 +202,34 @@ function ListQuery() {
let nextPage = null;
let previousPage = null;
- let hasNextPage = false;
- let hasPreviousPage = false;
let count = 0;
- if (!loading && !error && data.defaultRepository.bugs) {
- const bugs = data.defaultRepository.bugs;
- hasNextPage = bugs.pageInfo.hasNextPage;
- hasPreviousPage = bugs.pageInfo.hasPreviousPage;
+ if (!loading && !error && data?.repository?.bugs) {
+ const bugs = data.repository.bugs;
count = bugs.totalCount;
// This computes the URL for the next page
- nextPage = {
- ...location,
- search: editParams(params, p => {
- p.delete('last');
- p.delete('before');
- p.set('first', perPage);
- p.set('after', bugs.pageInfo.endCursor);
- }).toString(),
- };
+ if (bugs.pageInfo.hasNextPage) {
+ nextPage = {
+ ...location,
+ search: editParams(params, p => {
+ p.delete('last');
+ p.delete('before');
+ p.set('first', perPage);
+ p.set('after', bugs.pageInfo.endCursor);
+ }).toString(),
+ };
+ }
// and this for the previous page
- previousPage = {
- ...location,
- search: editParams(params, p => {
- p.delete('first');
- p.delete('after');
- p.set('last', perPage);
- p.set('before', bugs.pageInfo.startCursor);
- }).toString(),
- };
+ if (bugs.pageInfo.hasPreviousPage) {
+ previousPage = {
+ ...location,
+ search: editParams(params, p => {
+ p.delete('first');
+ p.delete('after');
+ p.set('last', perPage);
+ p.set('before', bugs.pageInfo.startCursor);
+ }).toString(),
+ };
+ }
}
// Prepare params without paging for editing filters
@@ -263,7 +240,7 @@ function ListQuery() {
p.delete('after');
});
// Returns a new location with the `q` param edited
- const queryLocation = query => ({
+ const queryLocation = (query: string) => ({
...location,
search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(),
});
@@ -273,8 +250,8 @@ function ListQuery() {
content = <Placeholder count={10} />;
} else if (error) {
content = <Error error={error} />;
- } else {
- const bugs = data.defaultRepository.bugs;
+ } else if (data?.repository) {
+ const bugs = data.repository.bugs;
if (bugs.totalCount === 0) {
content = <NoBug />;
@@ -283,7 +260,7 @@ function ListQuery() {
}
}
- const formSubmit = e => {
+ const formSubmit = (e: React.FormEvent) => {
e.preventDefault();
history.push(queryLocation(input));
};
@@ -296,7 +273,7 @@ function ListQuery() {
<InputBase
placeholder="Filter"
value={input}
- onInput={e => setInput(e.target.value)}
+ onInput={(e: any) => setInput(e.target.value)}
classes={{
root: classes.search,
focused: classes.searchFocused,
@@ -310,21 +287,25 @@ function ListQuery() {
<FilterToolbar query={query} queryLocation={queryLocation} />
{content}
<div className={classes.pagination}>
- <IconButton
- component={hasPreviousPage ? Link : 'button'}
- to={previousPage}
- disabled={!hasPreviousPage}
- >
- <KeyboardArrowLeft />
- </IconButton>
+ {previousPage ? (
+ <IconButton component={Link} to={previousPage}>
+ <KeyboardArrowLeft />
+ </IconButton>
+ ) : (
+ <IconButton disabled>
+ <KeyboardArrowLeft />
+ </IconButton>
+ )}
<div>{loading ? 'Loading' : `Total: ${count}`}</div>
- <IconButton
- component={hasNextPage ? Link : 'button'}
- to={nextPage}
- disabled={!hasNextPage}
- >
- <KeyboardArrowRight />
- </IconButton>
+ {nextPage ? (
+ <IconButton component={Link} to={nextPage}>
+ <KeyboardArrowRight />
+ </IconButton>
+ ) : (
+ <IconButton disabled>
+ <KeyboardArrowRight />
+ </IconButton>
+ )}
</div>
</Paper>
);
diff --git a/webui/src/react-app-env.d.ts b/webui/src/react-app-env.d.ts
new file mode 100644
index 00000000..6431bc5f
--- /dev/null
+++ b/webui/src/react-app-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="react-scripts" />
diff --git a/webui/src/tag/ImageTag.js b/webui/src/tag/ImageTag.tsx
index b0f0c1c8..bdb36873 100644
--- a/webui/src/tag/ImageTag.js
+++ b/webui/src/tag/ImageTag.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
import { makeStyles } from '@material-ui/styles';
+import React from 'react';
const useStyles = makeStyles({
tag: {
@@ -7,7 +7,10 @@ const useStyles = makeStyles({
},
});
-const ImageTag = ({ alt, ...props }) => {
+const ImageTag = ({
+ alt,
+ ...props
+}: React.ImgHTMLAttributes<HTMLImageElement>) => {
const classes = useStyles();
return (
<a href={props.src} target="_blank" rel="noopener noreferrer nofollow">
diff --git a/webui/src/tag/PreTag.js b/webui/src/tag/PreTag.tsx
index c2440df9..d3b4c273 100644
--- a/webui/src/tag/PreTag.js
+++ b/webui/src/tag/PreTag.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
import { makeStyles } from '@material-ui/styles';
+import React from 'react';
const useStyles = makeStyles({
tag: {
@@ -8,7 +8,7 @@ const useStyles = makeStyles({
},
});
-const PreTag = props => {
+const PreTag = (props: React.HTMLProps<HTMLPreElement>) => {
const classes = useStyles();
return <pre className={classes.tag} {...props}></pre>;
};