From 6a502c145bd8f2e2e1a9c0b103c11f0433c60737 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 4 Feb 2020 20:57:43 +0100 Subject: webui: convert bug list to typescript --- webui/src/list/BugRow.graphql | 14 ++ webui/src/list/BugRow.js | 119 -------------- webui/src/list/BugRow.tsx | 110 +++++++++++++ webui/src/list/List.js | 18 --- webui/src/list/List.tsx | 20 +++ webui/src/list/ListQuery.graphql | 37 +++++ webui/src/list/ListQuery.js | 333 --------------------------------------- webui/src/list/ListQuery.tsx | 313 ++++++++++++++++++++++++++++++++++++ 8 files changed, 494 insertions(+), 470 deletions(-) create mode 100644 webui/src/list/BugRow.graphql delete mode 100644 webui/src/list/BugRow.js create mode 100644 webui/src/list/BugRow.tsx delete mode 100644 webui/src/list/List.js create mode 100644 webui/src/list/List.tsx create mode 100644 webui/src/list/ListQuery.graphql delete mode 100644 webui/src/list/ListQuery.js create mode 100644 webui/src/list/ListQuery.tsx (limited to 'webui/src/list') 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.js deleted file mode 100644 index add5c12f..00000000 --- a/webui/src/list/BugRow.js +++ /dev/null @@ -1,119 +0,0 @@ -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 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'; - -const Open = ({ className }) => ( - - - -); - -const Closed = ({ className }) => ( - - - -); - -const Status = ({ status, className }) => { - switch (status) { - case 'OPEN': - return ; - case 'CLOSED': - return ; - default: - return 'unknown status ' + status; - } -}; - -const useStyles = makeStyles(theme => ({ - cell: { - display: 'flex', - alignItems: 'center', - padding: theme.spacing(1), - '& a': { - textDecoration: 'none', - }, - }, - status: { - margin: theme.spacing(1, 2), - }, - expand: { - width: '100%', - lineHeight: '20px', - }, - title: { - display: 'inline', - color: theme.palette.text.primary, - fontSize: '1.3rem', - fontWeight: 500, - }, - details: { - ...theme.typography.textSecondary, - lineHeight: '1.5rem', - color: theme.palette.text.secondary, - }, - labels: { - paddingLeft: theme.spacing(1), - '& > *': { - display: 'inline-block', - }, - }, -})); - -function BugRow({ bug }) { - const classes = useStyles(); - return ( - - - -
- -
- {bug.title} - {bug.labels.length > 0 && ( - - {bug.labels.map(l => ( - - )} -
- -
- {bug.humanId} opened - - by {bug.author.displayName} -
-
-
-
- ); -} - -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/BugRow.tsx b/webui/src/list/BugRow.tsx new file mode 100644 index 00000000..6979b296 --- /dev/null +++ b/webui/src/list/BugRow.tsx @@ -0,0 +1,110 @@ +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 React from 'react'; +import { Link } from 'react-router-dom'; +import Date from '../Date'; +import Label from '../Label'; +import { BugRowFragment } from './BugRow.generated'; +import { Status } from '../gqlTypes'; + +type OpenClosedProps = { className: string }; +const Open = ({ className }: OpenClosedProps) => ( + + + +); + +const Closed = ({ className }: OpenClosedProps) => ( + + + +); + +type StatusProps = { className: string; status: Status }; +const BugStatus: React.FC = ({ + status, + className, +}: StatusProps) => { + switch (status) { + case 'OPEN': + return ; + case 'CLOSED': + return ; + default: + return

{'unknown status ' + status}

; + } +}; + +const useStyles = makeStyles(theme => ({ + cell: { + display: 'flex', + alignItems: 'center', + padding: theme.spacing(1), + '& a': { + textDecoration: 'none', + }, + }, + status: { + margin: theme.spacing(1, 2), + }, + expand: { + width: '100%', + lineHeight: '20px', + }, + title: { + display: 'inline', + color: theme.palette.text.primary, + fontSize: '1.3rem', + fontWeight: 500, + }, + details: { + lineHeight: '1.5rem', + color: theme.palette.text.secondary, + }, + labels: { + paddingLeft: theme.spacing(1), + '& > *': { + display: 'inline-block', + }, + }, +})); + +type Props = { + bug: BugRowFragment; +}; + +function BugRow({ bug }: Props) { + const classes = useStyles(); + return ( + + + +
+ +
+ {bug.title} + {bug.labels.length > 0 && ( + + {bug.labels.map(l => ( + + )} +
+ +
+ {bug.humanId} opened + + by {bug.author.displayName} +
+
+
+
+ ); +} + +export default BugRow; diff --git a/webui/src/list/List.js b/webui/src/list/List.js deleted file mode 100644 index 63b73545..00000000 --- a/webui/src/list/List.js +++ /dev/null @@ -1,18 +0,0 @@ -import Table from '@material-ui/core/Table/Table'; -import TableBody from '@material-ui/core/TableBody/TableBody'; -import React from 'react'; -import BugRow from './BugRow'; - -function List({ bugs }) { - return ( - - - {bugs.edges.map(({ cursor, node }) => ( - - ))} - -
- ); -} - -export default List; diff --git a/webui/src/list/List.tsx b/webui/src/list/List.tsx new file mode 100644 index 00000000..23b193d4 --- /dev/null +++ b/webui/src/list/List.tsx @@ -0,0 +1,20 @@ +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'; + +type Props = { bugs: BugListFragment }; +function List({ bugs }: Props) { + return ( + + + {bugs.edges.map(({ cursor, node }) => ( + + ))} + +
+ ); +} + +export default List; diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql new file mode 100644 index 00000000..bf9ea80a --- /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 +) { + 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 + } + } +} diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js deleted file mode 100644 index 8eeec240..00000000 --- a/webui/src/list/ListQuery.js +++ /dev/null @@ -1,333 +0,0 @@ -import { fade, makeStyles } 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'; -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 List from './List'; -import FilterToolbar from './FilterToolbar'; - -const useStyles = makeStyles(theme => ({ - main: { - maxWidth: 800, - margin: 'auto', - marginTop: theme.spacing(4), - marginBottom: theme.spacing(4), - overflow: 'hidden', - }, - pagination: { - ...theme.typography.overline, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - header: { - display: 'flex', - padding: theme.spacing(2), - '& > h1': { - ...theme.typography.h6, - margin: theme.spacing(0, 2), - }, - alignItems: 'center', - justifyContent: 'space-between', - }, - search: { - borderRadius: theme.shape.borderRadius, - borderColor: fade(theme.palette.primary.main, 0.2), - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: fade(theme.palette.primary.main, 0.05), - padding: theme.spacing(0, 1), - width: ({ searching }) => (searching ? '20rem' : '15rem'), - transition: theme.transitions.create(), - }, - searchFocused: { - borderColor: fade(theme.palette.primary.main, 0.4), - backgroundColor: theme.palette.background.paper, - width: '20rem!important', - }, - placeholderRow: { - padding: theme.spacing(1), - borderBottomColor: theme.palette.grey['300'], - borderBottomWidth: '1px', - borderBottomStyle: 'solid', - display: 'flex', - alignItems: 'center', - }, - placeholderRowStatus: { - margin: theme.spacing(1, 2), - }, - placeholderRowText: { - flex: 1, - }, - message: { - ...theme.typography.h5, - padding: theme.spacing(8), - textAlign: 'center', - borderBottomColor: theme.palette.grey['300'], - borderBottomWidth: '1px', - borderBottomStyle: 'solid', - '& > p': { - margin: '0', - }, - }, - errorBox: { - color: theme.palette.error.main, - '& > pre': { - fontSize: '1rem', - textAlign: 'left', - backgroundColor: theme.palette.grey['900'], - color: theme.palette.common.white, - marginTop: theme.spacing(4), - padding: theme.spacing(2, 3), - }, - }, -})); - -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) { - const cloned = new URLSearchParams(params.toString()); - callback(cloned); - return cloned; -} - -// TODO: factor this out -const Placeholder = ({ count }) => { - const classes = useStyles(); - return ( - <> - {new Array(count).fill(null).map((_, i) => ( -
- -
- - -
-
- ))} - - ); -}; - -// TODO: factor this out -const NoBug = () => { - const classes = useStyles(); - return ( -
- -

No results matched your search.

-
- ); -}; - -const Error = ({ error }) => { - const classes = useStyles(); - return ( -
- -

There was an error while fetching bug.

-

- {error.message} -

-
-        {JSON.stringify(error, null, 2)}
-      
-
- ); -}; - -function ListQuery() { - const location = useLocation(); - const history = useHistory(); - const params = new URLSearchParams(location.search); - const query = params.get('q') || ''; - - const [input, setInput] = useState(query); - - const classes = useStyles({ searching: !!input }); - - // TODO is this the right way to do it? - const lastQuery = useRef(); - useEffect(() => { - if (query !== lastQuery.current) { - setInput(query); - } - lastQuery.current = query; - }, [query, input, lastQuery]); - - const page = { - first: params.get('first'), - last: params.get('last'), - after: params.get('after'), - before: params.get('before'), - }; - - // If nothing set, show the first 10 items - if (!page.first && !page.last) { - page.first = 10; - } - - const perPage = page.first || page.last; - - const { loading, error, data } = useQuery(QUERY, { - variables: { - ...page, - query, - }, - }); - - 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; - 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(), - }; - // 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(), - }; - } - - // Prepare params without paging for editing filters - const paramsWithoutPaging = editParams(params, p => { - p.delete('first'); - p.delete('last'); - p.delete('before'); - p.delete('after'); - }); - // Returns a new location with the `q` param edited - const queryLocation = query => ({ - ...location, - search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(), - }); - - let content; - if (loading) { - content = ; - } else if (error) { - content = ; - } else { - const bugs = data.defaultRepository.bugs; - - if (bugs.totalCount === 0) { - content = ; - } else { - content = ; - } - } - - const formSubmit = e => { - e.preventDefault(); - history.push(queryLocation(input)); - }; - - return ( - -
-

Issues

-
- setInput(e.target.value)} - classes={{ - root: classes.search, - focused: classes.searchFocused, - }} - /> - - -
- - {content} -
- - - -
{loading ? 'Loading' : `Total: ${count}`}
- - - -
-
- ); -} - -export default ListQuery; diff --git a/webui/src/list/ListQuery.tsx b/webui/src/list/ListQuery.tsx new file mode 100644 index 00000000..a9bb15df --- /dev/null +++ b/webui/src/list/ListQuery.tsx @@ -0,0 +1,313 @@ +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'; +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 React, { useState, useEffect, useRef } from 'react'; +import { useLocation, useHistory, Link } from 'react-router-dom'; +import { ApolloError } from 'apollo-boost'; +import List from './List'; +import FilterToolbar from './FilterToolbar'; +import { useListBugsQuery } from './ListQuery.generated'; + +type StylesProps = { searching?: boolean }; +const useStyles = makeStyles(theme => ({ + main: { + maxWidth: 800, + margin: 'auto', + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), + overflow: 'hidden', + }, + pagination: { + ...theme.typography.overline, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + header: { + display: 'flex', + padding: theme.spacing(2), + '& > h1': { + ...theme.typography.h6, + margin: theme.spacing(0, 2), + }, + alignItems: 'center', + justifyContent: 'space-between', + }, + search: { + borderRadius: theme.shape.borderRadius, + borderColor: fade(theme.palette.primary.main, 0.2), + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: fade(theme.palette.primary.main, 0.05), + padding: theme.spacing(0, 1), + width: ({ searching }) => (searching ? '20rem' : '15rem'), + transition: theme.transitions.create([ + 'width', + 'borderColor', + 'backgroundColor', + ]), + }, + searchFocused: { + borderColor: fade(theme.palette.primary.main, 0.4), + backgroundColor: theme.palette.background.paper, + width: '20rem!important', + }, + placeholderRow: { + padding: theme.spacing(1), + borderBottomColor: theme.palette.grey['300'], + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + display: 'flex', + alignItems: 'center', + }, + placeholderRowStatus: { + margin: theme.spacing(1, 2), + }, + placeholderRowText: { + flex: 1, + }, + message: { + ...theme.typography.h5, + padding: theme.spacing(8), + textAlign: 'center', + borderBottomColor: theme.palette.grey['300'], + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + '& > p': { + margin: '0', + }, + }, + errorBox: { + color: theme.palette.error.main, + '& > pre': { + fontSize: '1rem', + textAlign: 'left', + backgroundColor: theme.palette.grey['900'], + color: theme.palette.common.white, + marginTop: theme.spacing(4), + padding: theme.spacing(2, 3), + }, + }, +})); + +function editParams( + params: URLSearchParams, + callback: (params: URLSearchParams) => void +) { + const cloned = new URLSearchParams(params.toString()); + callback(cloned); + return cloned; +} + +// TODO: factor this out +type PlaceholderProps = { count: number }; +const Placeholder: React.FC = ({ + count, +}: PlaceholderProps) => { + const classes = useStyles({}); + return ( + <> + {new Array(count).fill(null).map((_, i) => ( +
+ +
+ + +
+
+ ))} + + ); +}; + +// TODO: factor this out +const NoBug = () => { + const classes = useStyles({}); + return ( +
+ +

No results matched your search.

+
+ ); +}; + +type ErrorProps = { error: ApolloError }; +const Error: React.FC = ({ error }: ErrorProps) => { + const classes = useStyles({}); + return ( +
+ +

There was an error while fetching bug.

+

+ {error.message} +

+
+        {JSON.stringify(error, null, 2)}
+      
+
+ ); +}; + +function ListQuery() { + const location = useLocation(); + const history = useHistory(); + const params = new URLSearchParams(location.search); + const query = params.get('q') || ''; + + const [input, setInput] = useState(query); + + const classes = useStyles({ searching: !!input }); + + // TODO is this the right way to do it? + const lastQuery = useRef(null); + useEffect(() => { + if (query !== lastQuery.current) { + setInput(query); + } + lastQuery.current = query; + }, [query, input, lastQuery]); + + const num = (param: string | null) => (param ? parseInt(param) : null); + const page = { + first: num(params.get('first')), + last: num(params.get('last')), + after: params.get('after'), + before: params.get('before'), + }; + + // If nothing set, show the first 10 items + if (!page.first && !page.last) { + page.first = 10; + } + + const perPage = (page.first || page.last || 10).toString(); + + const { loading, error, data } = useListBugsQuery({ + variables: { + ...page, + query, + }, + }); + + let nextPage = null; + let previousPage = null; + let count = 0; + if (!loading && !error && data?.defaultRepository?.bugs) { + const bugs = data.defaultRepository.bugs; + count = bugs.totalCount; + // This computes the URL for the next page + if (bugs.pageInfo.hasNextPage) { + nextPage = { + ...location, + search: editParams(params, p => { + p.delete('last'); + p.delete('before'); + p.set('first', perPage); + p.set('after', bugs.pageInfo.endCursor); + }).toString(), + }; + } + // and this for the previous page + if (bugs.pageInfo.hasPreviousPage) { + previousPage = { + ...location, + search: editParams(params, p => { + p.delete('first'); + p.delete('after'); + p.set('last', perPage); + p.set('before', bugs.pageInfo.startCursor); + }).toString(), + }; + } + } + + // Prepare params without paging for editing filters + const paramsWithoutPaging = editParams(params, p => { + p.delete('first'); + p.delete('last'); + p.delete('before'); + p.delete('after'); + }); + // Returns a new location with the `q` param edited + const queryLocation = (query: string) => ({ + ...location, + search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(), + }); + + let content; + if (loading) { + content = ; + } else if (error) { + content = ; + } else if (data?.defaultRepository) { + const bugs = data.defaultRepository.bugs; + + if (bugs.totalCount === 0) { + content = ; + } else { + content = ; + } + } + + const formSubmit = (e: React.FormEvent) => { + e.preventDefault(); + history.push(queryLocation(input)); + }; + + return ( + +
+

Issues

+
+ setInput(e.target.value)} + classes={{ + root: classes.search, + focused: classes.searchFocused, + }} + /> + + +
+ + {content} +
+ {previousPage ? ( + + + + ) : ( + + + + )} +
{loading ? 'Loading' : `Total: ${count}`}
+ {nextPage ? ( + + + + ) : ( + + + + )} +
+
+ ); +} + +export default ListQuery; -- cgit From e5f52401b2a839881fedef5a446f0ed21d2d34c2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Feb 2020 20:16:56 +0100 Subject: webui: typecheck remaining bug list components --- webui/src/list/Filter.js | 154 ---------------------------- webui/src/list/Filter.tsx | 189 +++++++++++++++++++++++++++++++++++ webui/src/list/FilterToolbar.graphql | 7 ++ webui/src/list/FilterToolbar.js | 125 ----------------------- webui/src/list/FilterToolbar.tsx | 127 +++++++++++++++++++++++ 5 files changed, 323 insertions(+), 279 deletions(-) delete mode 100644 webui/src/list/Filter.js create mode 100644 webui/src/list/Filter.tsx create mode 100644 webui/src/list/FilterToolbar.graphql delete mode 100644 webui/src/list/FilterToolbar.js create mode 100644 webui/src/list/FilterToolbar.tsx (limited to 'webui/src/list') diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js deleted file mode 100644 index a6cf3633..00000000 --- a/webui/src/list/Filter.js +++ /dev/null @@ -1,154 +0,0 @@ -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 ArrowDropDown from '@material-ui/icons/ArrowDropDown'; - -function parse(query) { - // TODO: extract the rest of the query? - const params = {}; - - // TODO: support escaping without quotes - const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; - let matches; - while ((matches = re.exec(query)) !== null) { - if (!params[matches[1]]) { - params[matches[1]] = []; - } - - let value; - if (matches[4]) { - value = matches[4]; - } else { - value = matches[2]; - } - value = value.replace(/\\(.)/g, '$1'); - params[matches[1]].push(value); - } - return params; -} - -function quote(value) { - const hasSingle = value.includes("'"); - const hasDouble = value.includes('"'); - const hasSpaces = value.includes(' '); - if (!hasSingle && !hasDouble && !hasSpaces) { - return value; - } - - if (!hasDouble) { - return `"${value}"`; - } - - if (!hasSingle) { - return `'${value}'`; - } - - value = value.replace(/"/g, '\\"'); - return `"${value}"`; -} - -function stringify(params) { - const parts = Object.entries(params).map(([key, values]) => { - return values.map(value => `${key}:${quote(value)}`); - }); - return [].concat(...parts).join(' '); -} - -const useStyles = makeStyles(theme => ({ - element: { - ...theme.typography.body2, - color: ({ active }) => (active ? '#333' : '#444'), - padding: theme.spacing(0, 1), - fontWeight: ({ active }) => (active ? 600 : 400), - textDecoration: 'none', - display: 'flex', - background: 'none', - border: 'none', - }, - itemActive: { - fontWeight: 600, - }, - icon: { - paddingRight: theme.spacing(0.5), - }, -})); - -function Dropdown({ children, dropdown, itemActive, to, ...props }) { - const [open, setOpen] = useState(false); - const buttonRef = useRef(); - const classes = useStyles(); - - return ( - <> - - setOpen(false)} - anchorEl={buttonRef.current} - > - {dropdown.map(([key, value]) => ( - setOpen(false)} - key={key} - > - {value} - - ))} - - - ); -} - -function Filter({ active, to, children, icon: Icon, dropdown, ...props }) { - const classes = useStyles({ active }); - - const content = ( - <> - {Icon && } -
{children}
- - ); - - if (dropdown) { - return ( - - {content} - - ); - } - - if (to) { - return ( - - {content} - - ); - } - - return
{content}
; -} - -export default Filter; -export { parse, stringify, quote }; diff --git a/webui/src/list/Filter.tsx b/webui/src/list/Filter.tsx new file mode 100644 index 00000000..d0091306 --- /dev/null +++ b/webui/src/list/Filter.tsx @@ -0,0 +1,189 @@ +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import { LocationDescriptor } from 'history'; +import clsx from 'clsx'; +import { makeStyles } from '@material-ui/core/styles'; +import { SvgIconProps } from '@material-ui/core/SvgIcon'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; + +export type Query = { [key: string]: Array }; + +function parse(query: string): Query { + // TODO: extract the rest of the query? + const params: Query = {}; + + // TODO: support escaping without quotes + const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; + let matches; + while ((matches = re.exec(query)) !== null) { + if (!params[matches[1]]) { + params[matches[1]] = []; + } + + let value; + if (matches[4]) { + value = matches[4]; + } else { + value = matches[2]; + } + value = value.replace(/\\(.)/g, '$1'); + params[matches[1]].push(value); + } + return params; +} + +function quote(value: string): string { + const hasSingle = value.includes("'"); + const hasDouble = value.includes('"'); + const hasSpaces = value.includes(' '); + if (!hasSingle && !hasDouble && !hasSpaces) { + return value; + } + + if (!hasDouble) { + return `"${value}"`; + } + + if (!hasSingle) { + return `'${value}'`; + } + + value = value.replace(/"/g, '\\"'); + return `"${value}"`; +} + +function stringify(params: Query): string { + const parts: string[][] = Object.entries(params).map(([key, values]) => { + return values.map(value => `${key}:${quote(value)}`); + }); + return new Array().concat(...parts).join(' '); +} + +const useStyles = makeStyles(theme => ({ + element: { + ...theme.typography.body2, + color: '#444', + padding: theme.spacing(0, 1), + fontWeight: 400, + textDecoration: 'none', + display: 'flex', + background: 'none', + border: 'none', + }, + itemActive: { + fontWeight: 600, + color: '#333', + }, + icon: { + paddingRight: theme.spacing(0.5), + }, +})); + +type DropdownTuple = [string, string]; + +type FilterDropdownProps = { + children: React.ReactNode; + dropdown: DropdownTuple[]; + itemActive: (key: string) => boolean; + icon?: React.ComponentType; + to: (key: string) => LocationDescriptor; +} & React.ButtonHTMLAttributes; + +function FilterDropdown({ + children, + dropdown, + itemActive, + icon: Icon, + to, + ...props +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + const classes = useStyles({ active: false }); + + const content = ( + <> + {Icon && } +
{children}
+ + ); + + return ( + <> + + setOpen(false)} + anchorEl={buttonRef.current} + > + {dropdown.map(([key, value]) => ( + setOpen(false)} + key={key} + > + {value} + + ))} + + + ); +} + +export type FilterProps = { + active: boolean; + to: LocationDescriptor; + icon?: React.ComponentType; + children: React.ReactNode; +}; +function Filter({ active, to, children, icon: Icon }: FilterProps) { + const classes = useStyles(); + + const content = ( + <> + {Icon && } +
{children}
+ + ); + + if (to) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + +export default Filter; +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..644a4eed --- /dev/null +++ b/webui/src/list/FilterToolbar.graphql @@ -0,0 +1,7 @@ +query BugCount($query: String) { + defaultRepository { + bugs: allBugs(query: $query) { + totalCount + } + } +} diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.js deleted file mode 100644 index 4d0b52b1..00000000 --- a/webui/src/list/FilterToolbar.js +++ /dev/null @@ -1,125 +0,0 @@ -import { makeStyles } from '@material-ui/styles'; -import { useQuery } from '@apollo/react-hooks'; -import gql from 'graphql-tag'; -import React from 'react'; -import Toolbar from '@material-ui/core/Toolbar'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import Filter, { parse, stringify } from './Filter'; - -// 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); - -const useStyles = makeStyles(theme => ({ - toolbar: { - backgroundColor: theme.palette.grey['100'], - borderColor: theme.palette.grey['300'], - borderWidth: '1px 0', - borderStyle: 'solid', - margin: theme.spacing(0, -1), - }, - spacer: { - flex: 1, - }, -})); - -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, { - variables: { query }, - }); - - var prefix; - if (loading) prefix = '...'; - else if (error) prefix = '???'; - // TODO: better prefixes & error handling - else prefix = data.defaultRepository.bugs.totalCount; - - return ( - - {prefix} {children} - - ); -} - -function FilterToolbar({ query, queryLocation }) { - const classes = useStyles(); - const params = 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 => ({ - ...params, - [key]: [value], - }); - const clearParam = key => params => ({ - ...params, - [key]: [], - }); - - // TODO: author/label filters - return ( - - - open - - - closed - -
- {/* - Author - Label - */} - hasValue('sort', key)} - to={key => pipe(params, replaceParam('sort', key), loc)} - > - Sort - - - ); -} - -export default FilterToolbar; diff --git a/webui/src/list/FilterToolbar.tsx b/webui/src/list/FilterToolbar.tsx new file mode 100644 index 00000000..2aaf7f84 --- /dev/null +++ b/webui/src/list/FilterToolbar.tsx @@ -0,0 +1,127 @@ +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { LocationDescriptor } from 'history'; +import { pipe } from '@arrows/composition'; +import Toolbar from '@material-ui/core/Toolbar'; +import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import { + FilterDropdown, + FilterProps, + Filter, + parse, + stringify, + Query, +} from './Filter'; +import { useBugCountQuery } from './FilterToolbar.generated'; + +const useStyles = makeStyles(theme => ({ + toolbar: { + backgroundColor: theme.palette.grey['100'], + borderColor: theme.palette.grey['300'], + borderWidth: '1px 0', + borderStyle: 'solid', + margin: theme.spacing(0, -1), + }, + spacer: { + flex: 1, + }, +})); + +// This prepends the filter text with a count +type CountingFilterProps = { + query: string; + children: React.ReactNode; +} & FilterProps; +function CountingFilter({ query, children, ...props }: CountingFilterProps) { + const { data, loading, error } = useBugCountQuery({ + variables: { query }, + }); + + var prefix; + if (loading) prefix = '...'; + else if (error || !data?.defaultRepository) prefix = '???'; + // TODO: better prefixes & error handling + else prefix = data.defaultRepository.bugs.totalCount; + + return ( + + {prefix} {children} + + ); +} + +type Props = { + query: string; + queryLocation: (query: string) => LocationDescriptor; +}; +function FilterToolbar({ query, queryLocation }: Props) { + const classes = useStyles(); + const params: Query = parse(query); + + const hasKey = (key: string): boolean => + params[key] && params[key].length > 0; + const hasValue = (key: string, value: string): boolean => + hasKey(key) && params[key].includes(value); + const loc = pipe(stringify, queryLocation); + const replaceParam = (key: string, value: string) => ( + params: Query + ): Query => ({ + ...params, + [key]: [value], + }); + const clearParam = (key: string) => (params: Query): Query => ({ + ...params, + [key]: [], + }); + + // TODO: author/label filters + return ( + + + open + + + closed + +
+ {/* + Author + Label + */} + hasValue('sort', key)} + to={key => pipe(replaceParam('sort', key), loc)(params)} + > + Sort + + + ); +} + +export default FilterToolbar; -- cgit From 9ddcb4b09215f942cb7889f9756d426ad3c90253 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Feb 2020 20:54:01 +0100 Subject: webui: force import order --- webui/src/list/BugRow.tsx | 8 +++++--- webui/src/list/Filter.tsx | 12 ++++++------ webui/src/list/FilterToolbar.tsx | 9 +++++---- webui/src/list/List.tsx | 1 + webui/src/list/ListQuery.tsx | 13 +++++++------ 5 files changed, 24 insertions(+), 19 deletions(-) (limited to 'webui/src/list') diff --git a/webui/src/list/BugRow.tsx b/webui/src/list/BugRow.tsx index 6979b296..f94538a7 100644 --- a/webui/src/list/BugRow.tsx +++ b/webui/src/list/BugRow.tsx @@ -1,16 +1,18 @@ -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 { makeStyles } from '@material-ui/core/styles'; import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +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 { BugRowFragment } from './BugRow.generated'; import { Status } from '../gqlTypes'; +import { BugRowFragment } from './BugRow.generated'; + type OpenClosedProps = { className: string }; const Open = ({ className }: OpenClosedProps) => ( diff --git a/webui/src/list/Filter.tsx b/webui/src/list/Filter.tsx index d0091306..30b52de8 100644 --- a/webui/src/list/Filter.tsx +++ b/webui/src/list/Filter.tsx @@ -1,12 +1,12 @@ -import React, { useState, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { LocationDescriptor } from 'history'; -import clsx from 'clsx'; -import { makeStyles } from '@material-ui/core/styles'; -import { SvgIconProps } from '@material-ui/core/SvgIcon'; 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'; export type Query = { [key: string]: Array }; diff --git a/webui/src/list/FilterToolbar.tsx b/webui/src/list/FilterToolbar.tsx index 2aaf7f84..df295054 100644 --- a/webui/src/list/FilterToolbar.tsx +++ b/webui/src/list/FilterToolbar.tsx @@ -1,10 +1,11 @@ -import { makeStyles } from '@material-ui/core/styles'; -import React from 'react'; -import { LocationDescriptor } from 'history'; 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 ErrorOutline from '@material-ui/icons/ErrorOutline'; +import { LocationDescriptor } from 'history'; +import React from 'react'; + import { FilterDropdown, FilterProps, diff --git a/webui/src/list/List.tsx b/webui/src/list/List.tsx index 23b193d4..cebd13f2 100644 --- a/webui/src/list/List.tsx +++ b/webui/src/list/List.tsx @@ -1,6 +1,7 @@ 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'; diff --git a/webui/src/list/ListQuery.tsx b/webui/src/list/ListQuery.tsx index a9bb15df..c91264e1 100644 --- a/webui/src/list/ListQuery.tsx +++ b/webui/src/list/ListQuery.tsx @@ -1,16 +1,17 @@ -import { fade, makeStyles, Theme } 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 { ApolloError } from 'apollo-boost'; import React, { useState, useEffect, useRef } from 'react'; import { useLocation, useHistory, Link } from 'react-router-dom'; -import { ApolloError } from 'apollo-boost'; -import List from './List'; + import FilterToolbar from './FilterToolbar'; +import List from './List'; import { useListBugsQuery } from './ListQuery.generated'; type StylesProps = { searching?: boolean }; -- cgit From 465f7ca73d7093eeeecb6553c804d8cfb06e6652 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Feb 2020 21:49:19 +0100 Subject: webui: stop using defaultRepository --- webui/src/list/FilterToolbar.graphql | 2 +- webui/src/list/FilterToolbar.tsx | 4 ++-- webui/src/list/ListQuery.graphql | 2 +- webui/src/list/ListQuery.tsx | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) (limited to 'webui/src/list') diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql index 644a4eed..cd103f44 100644 --- a/webui/src/list/FilterToolbar.graphql +++ b/webui/src/list/FilterToolbar.graphql @@ -1,5 +1,5 @@ query BugCount($query: String) { - defaultRepository { + repository { bugs: allBugs(query: $query) { totalCount } diff --git a/webui/src/list/FilterToolbar.tsx b/webui/src/list/FilterToolbar.tsx index df295054..b95b10bc 100644 --- a/webui/src/list/FilterToolbar.tsx +++ b/webui/src/list/FilterToolbar.tsx @@ -41,9 +41,9 @@ function CountingFilter({ query, children, ...props }: CountingFilterProps) { var prefix; if (loading) prefix = '...'; - else if (error || !data?.defaultRepository) 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 ( diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql index bf9ea80a..ded60c8a 100644 --- a/webui/src/list/ListQuery.graphql +++ b/webui/src/list/ListQuery.graphql @@ -7,7 +7,7 @@ query ListBugs( $before: String $query: String ) { - defaultRepository { + repository { bugs: allBugs( first: $first last: $last diff --git a/webui/src/list/ListQuery.tsx b/webui/src/list/ListQuery.tsx index c91264e1..84b72431 100644 --- a/webui/src/list/ListQuery.tsx +++ b/webui/src/list/ListQuery.tsx @@ -203,8 +203,8 @@ function ListQuery() { let nextPage = null; let previousPage = null; let count = 0; - if (!loading && !error && data?.defaultRepository?.bugs) { - const bugs = data.defaultRepository.bugs; + if (!loading && !error && data?.repository?.bugs) { + const bugs = data.repository.bugs; count = bugs.totalCount; // This computes the URL for the next page if (bugs.pageInfo.hasNextPage) { @@ -250,8 +250,8 @@ function ListQuery() { content = ; } else if (error) { content = ; - } else if (data?.defaultRepository) { - const bugs = data.defaultRepository.bugs; + } else if (data?.repository) { + const bugs = data.repository.bugs; if (bugs.totalCount === 0) { content = ; -- cgit