webui: convert bug list to typescript

Quentin Gliech created

Change summary

webui/codegen.yaml               |   4 
webui/package-lock.json          |  75 +++++++++++++--
webui/package.json               |   2 
webui/src/bug/Bug.tsx            |   2 
webui/src/bug/BugQuery.tsx       |   6 
webui/src/list/BugRow.graphql    |  14 ++
webui/src/list/BugRow.tsx        |  45 +++-----
webui/src/list/List.tsx          |   4 
webui/src/list/ListQuery.graphql |  37 +++++++
webui/src/list/ListQuery.tsx     | 168 ++++++++++++++-------------------
10 files changed, 218 insertions(+), 139 deletions(-)

Detailed changes

webui/codegen.yaml 🔗

@@ -12,6 +12,7 @@ generates:
     - typescript
   ./src/:
     plugins:
+    - add: '/* eslint-disable @typescript-eslint/no-unused-vars */'
     - typescript-operations
     - typescript-react-apollo
     preset: near-operation-file
@@ -23,9 +24,6 @@ generates:
       withHOC: false
       withHooks: true
 
-config:
-  documentMode: documentNode
-
 hooks:
   afterOneFileWrite:
   - prettier --write

webui/package-lock.json 🔗

@@ -1229,13 +1229,33 @@
       }
     },
     "@graphql-codegen/add": {
-      "version": "1.12.1",
-      "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-1.12.1.tgz",
-      "integrity": "sha512-i6+Al5+Z8WH4eIF4Nzsu2imXN1hLNPt+91v0Bm4n4XIOi3mbLtbEo8IxK354mOpriie1PCpUJq7Y9dofPONObA==",
+      "version": "1.12.2-alpha-ea7264f9.15",
+      "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-1.12.2-alpha-ea7264f9.15.tgz",
+      "integrity": "sha512-XfOZH2lIR3qw/mHqXThb32EA7NR37nPJpzuNtx1McGTy0sEEd5PVTLP4u89cgvMXfx18cMMM7ZWAnz2T7XCCkQ==",
       "dev": true,
       "requires": {
-        "@graphql-codegen/plugin-helpers": "1.12.1",
+        "@graphql-codegen/plugin-helpers": "1.12.2-alpha-ea7264f9.15+ea7264f9",
         "tslib": "1.10.0"
+      },
+      "dependencies": {
+        "@graphql-codegen/plugin-helpers": {
+          "version": "1.12.2-alpha-ea7264f9.15",
+          "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.12.2-alpha-ea7264f9.15.tgz",
+          "integrity": "sha512-EgRHQVFswVQUevMEtsrsA45JmTWj3UAUK8laMyDqbQQuOqlTOgpqdceTLYWWCpyfybaEaagw+rWpkwPXyUjWYQ==",
+          "dev": true,
+          "requires": {
+            "@graphql-toolkit/common": "0.9.7",
+            "camel-case": "4.1.1",
+            "common-tags": "1.8.0",
+            "constant-case": "3.0.3",
+            "import-from": "3.0.0",
+            "lower-case": "2.0.1",
+            "param-case": "3.0.3",
+            "pascal-case": "3.1.1",
+            "tslib": "1.10.0",
+            "upper-case": "2.0.1"
+          }
+        }
       }
     },
     "@graphql-codegen/cli": {
@@ -1316,15 +1336,50 @@
       }
     },
     "@graphql-codegen/near-operation-file-preset": {
-      "version": "1.12.1",
-      "resolved": "https://registry.npmjs.org/@graphql-codegen/near-operation-file-preset/-/near-operation-file-preset-1.12.1.tgz",
-      "integrity": "sha512-916+QqcUsnJOOgtRP4m7JDLnfwrQWufsRjdlDZL58pwrhNU0sRYObSyDH8RYhA8XIt5k29P+s2ZwHkGDRzGR1g==",
+      "version": "1.12.2-alpha-ea7264f9.15",
+      "resolved": "https://registry.npmjs.org/@graphql-codegen/near-operation-file-preset/-/near-operation-file-preset-1.12.2-alpha-ea7264f9.15.tgz",
+      "integrity": "sha512-jbj7+2FlHRLpqN3e44EZ88n2juImhMuXzv6Mlun4CEVkxC8zW6MYkptaeAxb+iCn2r2nO3vXNrNEPs/1czF97w==",
       "dev": true,
       "requires": {
-        "@graphql-codegen/add": "1.12.1",
-        "@graphql-codegen/plugin-helpers": "1.12.1",
-        "@graphql-codegen/visitor-plugin-common": "1.12.1",
+        "@graphql-codegen/add": "1.12.2-alpha-ea7264f9.15+ea7264f9",
+        "@graphql-codegen/plugin-helpers": "1.12.2-alpha-ea7264f9.15+ea7264f9",
+        "@graphql-codegen/visitor-plugin-common": "1.12.2-alpha-ea7264f9.15+ea7264f9",
         "tslib": "1.10.0"
+      },
+      "dependencies": {
+        "@graphql-codegen/plugin-helpers": {
+          "version": "1.12.2-alpha-ea7264f9.15",
+          "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.12.2-alpha-ea7264f9.15.tgz",
+          "integrity": "sha512-EgRHQVFswVQUevMEtsrsA45JmTWj3UAUK8laMyDqbQQuOqlTOgpqdceTLYWWCpyfybaEaagw+rWpkwPXyUjWYQ==",
+          "dev": true,
+          "requires": {
+            "@graphql-toolkit/common": "0.9.7",
+            "camel-case": "4.1.1",
+            "common-tags": "1.8.0",
+            "constant-case": "3.0.3",
+            "import-from": "3.0.0",
+            "lower-case": "2.0.1",
+            "param-case": "3.0.3",
+            "pascal-case": "3.1.1",
+            "tslib": "1.10.0",
+            "upper-case": "2.0.1"
+          }
+        },
+        "@graphql-codegen/visitor-plugin-common": {
+          "version": "1.12.2-alpha-ea7264f9.15",
+          "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.12.2-alpha-ea7264f9.15.tgz",
+          "integrity": "sha512-Y+4b5ArGOcXtGZ7gCLKhfOfiElH36uNSYs/8y0+9bxbjV1OuGfunnluysvpDSqIqatyVXviJh+P832VjO5Cviw==",
+          "dev": true,
+          "requires": {
+            "@graphql-codegen/plugin-helpers": "1.12.2-alpha-ea7264f9.15+ea7264f9",
+            "@graphql-toolkit/relay-operation-optimizer": "0.9.7",
+            "auto-bind": "4.0.0",
+            "dependency-graph": "0.8.1",
+            "graphql-tag": "2.10.1",
+            "pascal-case": "3.1.1",
+            "tslib": "1.10.0"
+          }
+        }
       }
     },
     "@graphql-codegen/plugin-helpers": {

webui/package.json 🔗

@@ -31,7 +31,7 @@
   "devDependencies": {
     "@graphql-codegen/cli": "^1.12.1",
     "@graphql-codegen/fragment-matcher": "^1.12.1",
-    "@graphql-codegen/near-operation-file-preset": "^1.12.1",
+    "@graphql-codegen/near-operation-file-preset": "^1.12.2-alpha-ea7264f9.15",
     "@graphql-codegen/typescript-operations": "^1.12.1",
     "@graphql-codegen/typescript-react-apollo": "^1.12.1",
     "eslint-config-prettier": "^6.10.0",

webui/src/bug/Bug.tsx 🔗

@@ -52,7 +52,7 @@ const useStyles = makeStyles(theme => ({
 }));
 
 type Props = {
-  bug: BugFragment
+  bug: BugFragment;
 };
 
 function Bug({ bug }: Props) {

webui/src/bug/BugQuery.tsx 🔗

@@ -6,11 +6,13 @@ import { useGetBugQuery } from './BugQuery.generated';
 import Bug from './Bug';
 
 type Props = RouteComponentProps<{
-  id: string
+  id: string;
 }>;
 
 const BugQuery: React.FC<Props> = ({ match }: Props) => {
-  const { loading, error, data } = useGetBugQuery({ variables: { id: match.params.id } });
+  const { loading, error, data } = useGetBugQuery({
+    variables: { id: match.params.id },
+  });
   if (loading) return <CircularProgress />;
   if (error) return <p>Error: {error}</p>;
   if (!data?.defaultRepository?.bug) return <p>404.</p>;

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
+}

webui/src/list/BugRow.js → webui/src/list/BugRow.tsx 🔗

@@ -1,36 +1,41 @@
-import { makeStyles } from '@material-ui/styles';
+import { makeStyles } from '@material-ui/core/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 CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
-import gql from 'graphql-tag';
 import React from 'react';
 import { Link } from 'react-router-dom';
 import Date from '../Date';
 import Label from '../Label';
-import Author from '../Author';
+import { BugRowFragment } from './BugRow.generated';
+import { Status } from '../gqlTypes';
 
-const Open = ({ className }) => (
+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 +62,6 @@ const useStyles = makeStyles(theme => ({
     fontWeight: 500,
   },
   details: {
-    ...theme.typography.textSecondary,
     lineHeight: '1.5rem',
     color: theme.palette.text.secondary,
   },
@@ -69,12 +73,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 +107,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;

webui/src/list/List.js → webui/src/list/List.tsx 🔗

@@ -2,8 +2,10 @@ 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>

webui/src/list/ListQuery.graphql 🔗

@@ -0,0 +1,37 @@
+#import "./BugRow.graphql"
+
+query ListBugs(
+  $first: Int
+  $last: Int
+  $after: String
+  $before: String
+  $query: String
+) {
+  defaultRepository {
+    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
+    }
+  }
+}

webui/src/list/ListQuery.js → webui/src/list/ListQuery.tsx 🔗

@@ -1,4 +1,4 @@
-import { fade, makeStyles } from '@material-ui/core/styles';
+import { fade, makeStyles, Theme } from '@material-ui/core/styles';
 import IconButton from '@material-ui/core/IconButton';
 import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
@@ -6,15 +6,15 @@ 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 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 { ApolloError } from 'apollo-boost';
 import List from './List';
 import FilterToolbar from './FilterToolbar';
+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 +46,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 +95,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 +132,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 +141,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 +169,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 +177,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 +190,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 +201,34 @@ function ListQuery() {
 
   let nextPage = null;
   let previousPage = null;
-  let hasNextPage = false;
-  let hasPreviousPage = false;
   let count = 0;
-  if (!loading && !error && data.defaultRepository.bugs) {
+  if (!loading && !error && data?.defaultRepository?.bugs) {
     const bugs = data.defaultRepository.bugs;
-    hasNextPage = bugs.pageInfo.hasNextPage;
-    hasPreviousPage = bugs.pageInfo.hasPreviousPage;
     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 +239,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,7 +249,7 @@ function ListQuery() {
     content = <Placeholder count={10} />;
   } else if (error) {
     content = <Error error={error} />;
-  } else {
+  } else if (data?.defaultRepository) {
     const bugs = data.defaultRepository.bugs;
 
     if (bugs.totalCount === 0) {
@@ -283,7 +259,7 @@ function ListQuery() {
     }
   }
 
-  const formSubmit = e => {
+  const formSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     history.push(queryLocation(input));
   };
@@ -296,7 +272,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 +286,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>
   );