diff options
author | Michael Muré <batolettre@gmail.com> | 2020-02-14 22:56:59 +0100 |
---|---|---|
committer | Michael Muré <batolettre@gmail.com> | 2020-02-14 22:56:59 +0100 |
commit | e9aff2a2a103b43852ecf7b57ae9ab297890eeed (patch) | |
tree | d66cb75151e42ada31e1d0179f8dba0ace388989 /webui/src | |
parent | b2ca506210b3eb63c4964e5bb47203fd5341ddf4 (diff) | |
parent | 2df72942f2b057956c7873f908b64880ab647331 (diff) | |
download | git-bug-e9aff2a2a103b43852ecf7b57ae9ab297890eeed.tar.gz |
Merge remote-tracking branch 'origin/master' into cheshirekow-jira
Diffstat (limited to 'webui/src')
-rw-r--r-- | webui/src/.gitignore | 5 | ||||
-rw-r--r-- | webui/src/App.tsx (renamed from webui/src/App.js) | 15 | ||||
-rw-r--r-- | webui/src/Author.graphql | 8 | ||||
-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.graphql | 8 | ||||
-rw-r--r-- | webui/src/CurrentIdentity.js | 45 | ||||
-rw-r--r-- | webui/src/CurrentIdentity.tsx | 30 | ||||
-rw-r--r-- | webui/src/Date.tsx (renamed from webui/src/Date.js) | 5 | ||||
-rw-r--r-- | webui/src/Label.graphql | 8 | ||||
-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.graphql | 14 | ||||
-rw-r--r-- | webui/src/bug/Bug.tsx (renamed from webui/src/bug/Bug.js) | 30 | ||||
-rw-r--r-- | webui/src/bug/BugQuery.graphql | 9 | ||||
-rw-r--r-- | webui/src/bug/BugQuery.js | 30 | ||||
-rw-r--r-- | webui/src/bug/BugQuery.tsx | 22 | ||||
-rw-r--r-- | webui/src/bug/LabelChange.tsx (renamed from webui/src/bug/LabelChange.js) | 30 | ||||
-rw-r--r-- | webui/src/bug/LabelChangeFragment.graphql | 13 | ||||
-rw-r--r-- | webui/src/bug/Message.tsx (renamed from webui/src/bug/Message.js) | 41 | ||||
-rw-r--r-- | webui/src/bug/MessageCommentFragment.graphql | 8 | ||||
-rw-r--r-- | webui/src/bug/MessageCreateFragment.graphql | 8 | ||||
-rw-r--r-- | webui/src/bug/SetStatus.tsx (renamed from webui/src/bug/SetStatus.js) | 24 | ||||
-rw-r--r-- | webui/src/bug/SetStatusFragment.graphql | 7 | ||||
-rw-r--r-- | webui/src/bug/SetTitle.tsx (renamed from webui/src/bug/SetTitle.js) | 25 | ||||
-rw-r--r-- | webui/src/bug/SetTitleFragment.graphql | 8 | ||||
-rw-r--r-- | webui/src/bug/Timeline.js | 43 | ||||
-rw-r--r-- | webui/src/bug/Timeline.tsx | 48 | ||||
-rw-r--r-- | webui/src/bug/TimelineQuery.graphql | 39 | ||||
-rw-r--r-- | webui/src/bug/TimelineQuery.js | 53 | ||||
-rw-r--r-- | webui/src/bug/TimelineQuery.tsx | 30 | ||||
-rw-r--r-- | webui/src/index.tsx (renamed from webui/src/index.js) | 4 | ||||
-rw-r--r-- | webui/src/list/BugRow.graphql | 14 | ||||
-rw-r--r-- | webui/src/list/BugRow.tsx (renamed from webui/src/list/BugRow.js) | 49 | ||||
-rw-r--r-- | webui/src/list/Filter.tsx (renamed from webui/src/list/Filter.js) | 103 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.graphql | 7 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.tsx (renamed from webui/src/list/FilterToolbar.js) | 85 | ||||
-rw-r--r-- | webui/src/list/List.tsx (renamed from webui/src/list/List.js) | 5 | ||||
-rw-r--r-- | webui/src/list/ListQuery.graphql | 37 | ||||
-rw-r--r-- | webui/src/list/ListQuery.tsx (renamed from webui/src/list/ListQuery.js) | 181 | ||||
-rw-r--r-- | webui/src/react-app-env.d.ts | 1 | ||||
-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>; }; |