From 5374a74ecbd6a41719371b457ca1f876868c54c8 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 21 Jan 2020 21:01:51 +0100 Subject: webui: use the IntrospectionFragmentMatcher & update dependencies --- webui/src/.gitignore | 1 + webui/src/index.js | 10 ++++++++++ webui/src/list/BugRow.js | 1 - 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 webui/src/.gitignore (limited to 'webui/src') diff --git a/webui/src/.gitignore b/webui/src/.gitignore new file mode 100644 index 00000000..5134e469 --- /dev/null +++ b/webui/src/.gitignore @@ -0,0 +1 @@ +fragmentTypes.js diff --git a/webui/src/index.js b/webui/src/index.js index f71e82f3..6f838c69 100644 --- a/webui/src/index.js +++ b/webui/src/index.js @@ -1,17 +1,27 @@ import ThemeProvider from '@material-ui/styles/ThemeProvider'; import { createMuiTheme } from '@material-ui/core/styles'; import ApolloClient from 'apollo-boost'; +import { + IntrospectionFragmentMatcher, + InMemoryCache, +} from 'apollo-cache-inmemory'; import React from 'react'; import { ApolloProvider } from 'react-apollo'; import ReactDOM from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; +import introspectionQueryResultData from './fragmentTypes'; import App from './App'; const theme = createMuiTheme(); const client = new ApolloClient({ uri: '/graphql', + cache: new InMemoryCache({ + fragmentMatcher: new IntrospectionFragmentMatcher({ + introspectionQueryResultData, + }), + }), }); ReactDOM.render( diff --git a/webui/src/list/BugRow.js b/webui/src/list/BugRow.js index 7b601916..23414a36 100644 --- a/webui/src/list/BugRow.js +++ b/webui/src/list/BugRow.js @@ -2,7 +2,6 @@ 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 Typography from '@material-ui/core/Typography'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; import gql from 'graphql-tag'; import React from 'react'; -- cgit From 42219ab655090ea1194e1b8e280ed8e86b31abbe Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Thu, 23 Jan 2020 21:36:49 +0100 Subject: webui: custom image tag --- webui/src/Content.js | 7 ++++++- webui/src/tag/ImageTag.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 webui/src/tag/ImageTag.js (limited to 'webui/src') diff --git a/webui/src/Content.js b/webui/src/Content.js index 19f57631..737c5ab2 100644 --- a/webui/src/Content.js +++ b/webui/src/Content.js @@ -2,12 +2,17 @@ import unified from 'unified'; import parse from 'remark-parse'; import html from 'remark-html'; import remark2react from 'remark-react'; +import ImageTag from './tag/ImageTag'; const Content = ({ markdown }) => { const processor = unified() .use(parse) .use(html) - .use(remark2react); + .use(remark2react, { + remarkReactComponents: { + img: ImageTag, + }, + }); return processor.processSync(markdown).contents; }; diff --git a/webui/src/tag/ImageTag.js b/webui/src/tag/ImageTag.js new file mode 100644 index 00000000..aea65e9d --- /dev/null +++ b/webui/src/tag/ImageTag.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; + +const useStyles = makeStyles({ + tag: { + maxWidth: '100%', + }, +}); + +const ImageTag = (props) => { + const classes = useStyles(); + return +}; + +export default ImageTag; \ No newline at end of file -- cgit From e08ecf1a7f8d93b584c0c5130e4333d6621a4c52 Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Thu, 23 Jan 2020 21:40:53 +0100 Subject: webui: fix column width on bug --- webui/src/bug/Bug.js | 1 + 1 file changed, 1 insertion(+) (limited to 'webui/src') diff --git a/webui/src/bug/Bug.js b/webui/src/bug/Bug.js index 19b8b9ce..5a159f0f 100644 --- a/webui/src/bug/Bug.js +++ b/webui/src/bug/Bug.js @@ -31,6 +31,7 @@ const useStyles = makeStyles(theme => ({ flex: 1, marginTop: theme.spacing(2), marginRight: theme.spacing(2), + minWidth: 0, }, sidebar: { marginTop: theme.spacing(2), -- cgit From 3413ee448a79686381624a414b18debd09120b9e Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Thu, 23 Jan 2020 21:50:20 +0100 Subject: webui: open image in a new tab on click --- webui/src/tag/ImageTag.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'webui/src') diff --git a/webui/src/tag/ImageTag.js b/webui/src/tag/ImageTag.js index aea65e9d..b0f0c1c8 100644 --- a/webui/src/tag/ImageTag.js +++ b/webui/src/tag/ImageTag.js @@ -7,9 +7,13 @@ const useStyles = makeStyles({ }, }); -const ImageTag = (props) => { +const ImageTag = ({ alt, ...props }) => { const classes = useStyles(); - return + return ( + + {alt} + + ); }; -export default ImageTag; \ No newline at end of file +export default ImageTag; -- cgit From e364674850d527fe3a3c9bb8e3745ee619784636 Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Thu, 23 Jan 2020 22:03:22 +0100 Subject: webui: fix width for pre tags in bug messages --- webui/src/Content.js | 2 ++ webui/src/bug/Message.js | 1 + webui/src/tag/PreTag.js | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 webui/src/tag/PreTag.js (limited to 'webui/src') diff --git a/webui/src/Content.js b/webui/src/Content.js index 737c5ab2..3a6900bc 100644 --- a/webui/src/Content.js +++ b/webui/src/Content.js @@ -3,6 +3,7 @@ import parse from 'remark-parse'; import html from 'remark-html'; import remark2react from 'remark-react'; import ImageTag from './tag/ImageTag'; +import PreTag from './tag/PreTag'; const Content = ({ markdown }) => { const processor = unified() @@ -11,6 +12,7 @@ const Content = ({ markdown }) => { .use(remark2react, { remarkReactComponents: { img: ImageTag, + pre: PreTag, }, }); diff --git a/webui/src/bug/Message.js b/webui/src/bug/Message.js index db67a3f5..06c12815 100644 --- a/webui/src/bug/Message.js +++ b/webui/src/bug/Message.js @@ -20,6 +20,7 @@ const useStyles = makeStyles(theme => ({ bubble: { flex: 1, marginLeft: theme.spacing(1), + minWidth: 0, }, header: { ...theme.typography.body1, diff --git a/webui/src/tag/PreTag.js b/webui/src/tag/PreTag.js new file mode 100644 index 00000000..c2440df9 --- /dev/null +++ b/webui/src/tag/PreTag.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; + +const useStyles = makeStyles({ + tag: { + maxWidth: '100%', + overflowX: 'auto', + }, +}); + +const PreTag = props => { + const classes = useStyles(); + return
;
+};
+
+export default PreTag;
-- 
cgit 


From f716bc1db9af25af96c59f9eee9c34525f7a88f0 Mon Sep 17 00:00:00 2001
From: ludovicm67 
Date: Thu, 23 Jan 2020 22:49:19 +0100
Subject: webui: change primary color

---
 webui/src/App.js | 13 +++++++++++--
 1 file changed, 11 insertions(+), 2 deletions(-)

(limited to 'webui/src')

diff --git a/webui/src/App.js b/webui/src/App.js
index 4a52eca1..2dddb3eb 100644
--- a/webui/src/App.js
+++ b/webui/src/App.js
@@ -1,5 +1,6 @@
 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 React from 'react';
@@ -9,6 +10,14 @@ import { Link } from 'react-router-dom';
 import BugQuery from './bug/BugQuery';
 import ListQuery from './list/ListQuery';
 
+const theme = createMuiTheme({
+  palette: {
+    primary: {
+      main: '#263238',
+    },
+  },
+});
+
 const useStyles = makeStyles(theme => ({
   appTitle: {
     ...theme.typography.h6,
@@ -21,7 +30,7 @@ export default function App() {
   const classes = useStyles();
 
   return (
-    <>
+    
       
       
         
@@ -34,6 +43,6 @@ export default function App() {
         
         
       
-    
+    
   );
 }
-- 
cgit 


From 8f6bc245038ce7197034877bf0fd5ddcb8d3e24d Mon Sep 17 00:00:00 2001
From: ludovicm67 
Date: Thu, 23 Jan 2020 23:05:34 +0100
Subject: webui: fix AppBar

---
 webui/src/App.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

(limited to 'webui/src')

diff --git a/webui/src/App.js b/webui/src/App.js
index 2dddb3eb..145cd90d 100644
--- a/webui/src/App.js
+++ b/webui/src/App.js
@@ -19,10 +19,12 @@ const theme = createMuiTheme({
 });
 
 const useStyles = makeStyles(theme => ({
+  offset: theme.mixins.toolbar,
   appTitle: {
     ...theme.typography.h6,
     color: 'white',
     textDecoration: 'none',
+    flexGrow: 1,
   },
 }));
 
@@ -32,13 +34,14 @@ export default function App() {
   return (
     
       
-      
+      
         
           
             git-bug webui
           
         
       
+      
-- cgit From def48e53f4ae206a10d0973439efdd7769e91100 Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Thu, 23 Jan 2020 23:37:59 +0100 Subject: webui: display current identity in the AppBar --- webui/src/App.js | 12 ++++++++--- webui/src/CurrentIdentity.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 webui/src/CurrentIdentity.js (limited to 'webui/src') diff --git a/webui/src/App.js b/webui/src/App.js index 145cd90d..35778268 100644 --- a/webui/src/App.js +++ b/webui/src/App.js @@ -9,6 +9,7 @@ import { Link } from 'react-router-dom'; import BugQuery from './bug/BugQuery'; import ListQuery from './list/ListQuery'; +import CurrentIdentity from './CurrentIdentity'; const theme = createMuiTheme({ palette: { @@ -24,6 +25,8 @@ const useStyles = makeStyles(theme => ({ ...theme.typography.h6, color: 'white', textDecoration: 'none', + }, + headerLeft: { flexGrow: 1, }, })); @@ -36,9 +39,12 @@ export default function App() { - - git-bug webui - +
+ + git-bug webui + +
+
diff --git a/webui/src/CurrentIdentity.js b/webui/src/CurrentIdentity.js new file mode 100644 index 00000000..c8afc531 --- /dev/null +++ b/webui/src/CurrentIdentity.js @@ -0,0 +1,51 @@ +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 ( + + {({ error, data }) => { + if ( + error || + !data || + !data.defaultRepository || + !data.defaultRepository.userIdentity || + !data.defaultRepository.userIdentity.displayName + ) + return <>; + const displayName = + data.defaultRepository.userIdentity.displayName || ''; + const avatar = data.defaultRepository.userIdentity.avatarUrl; + return ( + <> + {displayName.charAt(0).toUpperCase()} +
{displayName}
+ + ); + }} +
+ ); +}; + +export default CurrentIdentity; -- cgit From 7de5a25ffa0b2c45db9cb2da0e142b5b5b6d5b7d Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Fri, 24 Jan 2020 00:28:18 +0100 Subject: webui: add logo --- webui/src/App.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) (limited to 'webui/src') diff --git a/webui/src/App.js b/webui/src/App.js index 35778268..b9c57327 100644 --- a/webui/src/App.js +++ b/webui/src/App.js @@ -21,13 +21,19 @@ const theme = createMuiTheme({ const useStyles = makeStyles(theme => ({ offset: theme.mixins.toolbar, + filler: { + flexGrow: 1, + }, appTitle: { ...theme.typography.h6, color: 'white', textDecoration: 'none', + display: 'flex', + alignItems: 'center', }, - headerLeft: { - flexGrow: 1, + logo: { + height: '42px', + marginRight: theme.spacing(2), }, })); @@ -39,11 +45,11 @@ export default function App() { -
- - git-bug webui - -
+ + git-bug + git-bug + +
-- cgit From 70354165ff1956dd0598ff69736fb0436612003c Mon Sep 17 00:00:00 2001 From: ludovicm67 Date: Fri, 24 Jan 2020 00:43:12 +0100 Subject: webui: remove useless conditions --- webui/src/CurrentIdentity.js | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) (limited to 'webui/src') diff --git a/webui/src/CurrentIdentity.js b/webui/src/CurrentIdentity.js index c8afc531..451979fb 100644 --- a/webui/src/CurrentIdentity.js +++ b/webui/src/CurrentIdentity.js @@ -25,22 +25,16 @@ const CurrentIdentity = () => { const classes = useStyles(); return ( - {({ error, data }) => { - if ( - error || - !data || - !data.defaultRepository || - !data.defaultRepository.userIdentity || - !data.defaultRepository.userIdentity.displayName - ) - return <>; - const displayName = - data.defaultRepository.userIdentity.displayName || ''; - const avatar = data.defaultRepository.userIdentity.avatarUrl; + {({ loading, error, data }) => { + if (error || loading || !data.defaultRepository.userIdentity) + return null; + const user = data.defaultRepository.userIdentity; return ( <> - {displayName.charAt(0).toUpperCase()} -
{displayName}
+ + {user.displayName.charAt(0).toUpperCase()} + +
{user.displayName}
); }} -- cgit From fa13550115144a6f39888960a80cc24890f83536 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 24 Jan 2020 00:43:02 +0100 Subject: webui: enhance the issue list page This starts some ground work for filtering & moves the pagination logic in the query params. Also has a nice loading placeholder. --- webui/src/list/BugRow.js | 3 +- webui/src/list/Filter.js | 32 ++++++ webui/src/list/List.js | 48 ++------- webui/src/list/ListQuery.js | 237 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 255 insertions(+), 65 deletions(-) create mode 100644 webui/src/list/Filter.js (limited to 'webui/src') diff --git a/webui/src/list/BugRow.js b/webui/src/list/BugRow.js index 23414a36..add5c12f 100644 --- a/webui/src/list/BugRow.js +++ b/webui/src/list/BugRow.js @@ -3,6 +3,7 @@ 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'; @@ -18,7 +19,7 @@ const Open = ({ className }) => ( const Closed = ({ className }) => ( - + ); diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js new file mode 100644 index 00000000..ce457d03 --- /dev/null +++ b/webui/src/list/Filter.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; + +const useStyles = makeStyles(theme => ({ + element: { + ...theme.typography.body2, + color: ({ active }) => (active ? '#333' : '#444'), + padding: theme.spacing(0, 1), + fontWeight: ({ active }) => (active ? 500 : 400), + textDecoration: 'none', + display: 'flex', + alignSelf: ({ end }) => (end ? 'flex-end' : 'auto'), + background: 'none', + border: 'none', + }, + icon: { + paddingRight: theme.spacing(0.5), + }, +})); + +function Filter({ active, children, icon: Icon, end, ...props }) { + const classes = useStyles({ active, end }); + + return ( + + ); +} + +export default Filter; diff --git a/webui/src/list/List.js b/webui/src/list/List.js index 54b2fe97..63b73545 100644 --- a/webui/src/list/List.js +++ b/webui/src/list/List.js @@ -1,49 +1,17 @@ -import { makeStyles } from '@material-ui/styles'; -import IconButton from '@material-ui/core/IconButton'; import Table from '@material-ui/core/Table/Table'; import TableBody from '@material-ui/core/TableBody/TableBody'; -import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; -import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; import React from 'react'; import BugRow from './BugRow'; -const useStyles = makeStyles(theme => ({ - main: { - maxWidth: 600, - margin: 'auto', - marginTop: theme.spacing(4), - }, - pagination: { - ...theme.typography.overline, - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - }, -})); - -function List({ bugs, nextPage, prevPage }) { - const classes = useStyles(); - const { hasNextPage, hasPreviousPage } = bugs.pageInfo; +function List({ bugs }) { return ( -
- - - {bugs.edges.map(({ cursor, node }) => ( - - ))} - -
- -
-
Total: {bugs.totalCount}
- - - - - - -
-
+ + + {bugs.edges.map(({ cursor, node }) => ( + + ))} + +
); } diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js index 869bca79..9cbfab67 100644 --- a/webui/src/list/ListQuery.js +++ b/webui/src/list/ListQuery.js @@ -1,19 +1,90 @@ -// @flow -import CircularProgress from '@material-ui/core/CircularProgress'; +import { makeStyles } from '@material-ui/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Toolbar from '@material-ui/core/Toolbar'; +import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import Paper from '@material-ui/core/Paper'; +import Filter from './Filter'; +import Skeleton from '@material-ui/lab/Skeleton'; import gql from 'graphql-tag'; -import React, { useState } from 'react'; -import { Query } from 'react-apollo'; +import React from 'react'; +import { useQuery } from '@apollo/react-hooks'; +import { useLocation, Link } from 'react-router-dom'; import BugRow from './BugRow'; import List from './List'; +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', + }, + toolbar: { + backgroundColor: theme.palette.grey['100'], + borderColor: theme.palette.grey['300'], + borderWidth: '1px 0', + borderStyle: 'solid', + margin: theme.spacing(0, -1), + }, + header: { + ...theme.typography.h6, + padding: theme.spacing(2, 4), + }, + spacer: { + flex: 1, + }, + 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, + }, + noBug: { + ...theme.typography.h5, + padding: theme.spacing(8), + textAlign: 'center', + borderBottomColor: theme.palette.grey['300'], + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + '& > p': { + margin: '0', + }, + }, +})); + const QUERY = gql` - query($first: Int, $last: Int, $after: String, $before: String) { + 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 { @@ -35,30 +106,148 @@ const QUERY = gql` ${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.

+
+ ); +}; + function ListQuery() { - const [page, setPage] = useState({ first: 10, after: null }); + const classes = useStyles(); + const location = useLocation(); + const params = new URLSearchParams(location.search); + const query = params.get('q'); + 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 nextPage = pageInfo => - setPage({ first: perPage, after: pageInfo.endCursor }); - const prevPage = pageInfo => - setPage({ last: perPage, before: pageInfo.startCursor }); + + 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(), + }; + } + + let content; + if (loading) { + content = ; + } else if (error) { + content =

Error: {JSON.stringify(error)}

; + } else { + const bugs = data.defaultRepository.bugs; + + if (bugs.totalCount === 0) { + content = ; + } else { + content = ; + } + } return ( - - {({ loading, error, data }) => { - if (loading) return ; - if (error) return

Error: {error}

; - const bugs = data.defaultRepository.bugs; - return ( - nextPage(bugs.pageInfo)} - prevPage={() => prevPage(bugs.pageInfo)} - /> - ); - }} -
+ +
Issues
+ + {/* TODO */} + + 123 open + + 456 closed +
+ Author + Label + Sort + + {content} +
+ + + +
{loading ? 'Loading' : `Total: ${count}`}
+ + + +
+ ); } -- cgit From 4d97e3a19a96e2361b35a0ccc0be74e0ba887214 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sat, 25 Jan 2020 11:40:08 +0100 Subject: webui: implement filtering --- webui/src/__tests__/query.js | 62 ++++++++++++++++++ webui/src/list/Filter.js | 73 ++++++++++++++++++++- webui/src/list/FilterToolbar.js | 60 +++++++++++++++++ webui/src/list/ListQuery.js | 140 ++++++++++++++++++++++++++++++---------- 4 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 webui/src/__tests__/query.js create mode 100644 webui/src/list/FilterToolbar.js (limited to 'webui/src') diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js new file mode 100644 index 00000000..1415af02 --- /dev/null +++ b/webui/src/__tests__/query.js @@ -0,0 +1,62 @@ +import { parse, stringify, quote } from '../list/Filter'; + +it('parses a simple query', () => { + expect(parse('foo:bar')).toEqual({ + foo: ['bar'], + }); +}); + +it('parses a query with multiple filters', () => { + expect(parse('foo:bar baz:foobar')).toEqual({ + foo: ['bar'], + baz: ['foobar'], + }); +}); + +it('parses a quoted query', () => { + expect(parse('foo:"bar"')).toEqual({ + foo: ['bar'], + }); + + expect(parse("foo:'bar'")).toEqual({ + foo: ['bar'], + }); + + expect(parse('foo:\'bar "nested" quotes\'')).toEqual({ + foo: ['bar "nested" quotes'], + }); + + expect(parse("foo:'escaped\\' quotes'")).toEqual({ + foo: ["escaped' quotes"], + }); +}); + +it('parses a query with repetitions', () => { + expect(parse('foo:bar foo:baz')).toEqual({ + foo: ['bar', 'baz'], + }); +}); + +it('parses a complex query', () => { + expect(parse('foo:bar foo:baz baz:"foobar" idont:\'know\'')).toEqual({ + foo: ['bar', 'baz'], + baz: ['foobar'], + idont: ['know'], + }); +}); + +it('quotes values', () => { + expect(quote('foo')).toEqual('foo'); + expect(quote('foo bar')).toEqual('"foo bar"'); + expect(quote('foo "bar"')).toEqual(`'foo "bar"'`); + expect(quote(`foo "bar" 'baz'`)).toEqual(`"foo \\"bar\\" 'baz'"`); +}); + +it('stringifies params', () => { + expect(stringify({ foo: ['bar'] })).toEqual('foo:bar'); + expect(stringify({ foo: ['bar baz'] })).toEqual('foo:"bar baz"'); + expect(stringify({ foo: ['bar', 'baz'] })).toEqual('foo:bar foo:baz'); + expect(stringify({ foo: ['bar'], baz: ['foobar'] })).toEqual( + 'foo:bar baz:foobar' + ); +}); diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js index ce457d03..c93b2d35 100644 --- a/webui/src/list/Filter.js +++ b/webui/src/list/Filter.js @@ -1,6 +1,58 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import { makeStyles } from '@material-ui/styles'; +function parse(query) { + // TODO: extract the rest of the query? + const params = {}; + + // TODO: support escaping without quotes + const re = /(\w+):(\w+|(["'])(([^\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, @@ -18,15 +70,30 @@ const useStyles = makeStyles(theme => ({ }, })); -function Filter({ active, children, icon: Icon, end, ...props }) { +function Filter({ active, to, children, icon: Icon, end, ...props }) { const classes = useStyles({ active, end }); - return ( - ); } export default Filter; +export { parse, stringify, quote }; diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.js new file mode 100644 index 00000000..e6d6f4ed --- /dev/null +++ b/webui/src/list/FilterToolbar.js @@ -0,0 +1,60 @@ +import { makeStyles } from '@material-ui/styles'; +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'; + +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, + }, +})); + +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 replaceParam = (key, value) => { + const p = { + ...params, + [key]: [value], + }; + return queryLocation(stringify(p)); + }; + + // TODO: open/closed count + // TODO: author/label/sort filters + return ( + + + open + + + closed + +
+ Author + Label + Sort + + ); +} + +export default FilterToolbar; diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js index 9cbfab67..b6a29702 100644 --- a/webui/src/list/ListQuery.js +++ b/webui/src/list/ListQuery.js @@ -1,19 +1,18 @@ -import { makeStyles } from '@material-ui/styles'; +import { fade, makeStyles } from '@material-ui/core/styles'; import IconButton from '@material-ui/core/IconButton'; -import Toolbar from '@material-ui/core/Toolbar'; import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; import Paper from '@material-ui/core/Paper'; -import Filter from './Filter'; +import InputBase from '@material-ui/core/InputBase'; import Skeleton from '@material-ui/lab/Skeleton'; import gql from 'graphql-tag'; -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useQuery } from '@apollo/react-hooks'; -import { useLocation, Link } from 'react-router-dom'; +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: { @@ -29,19 +28,28 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', justifyContent: 'center', }, - toolbar: { - backgroundColor: theme.palette.grey['100'], - borderColor: theme.palette.grey['300'], - borderWidth: '1px 0', - borderStyle: 'solid', - margin: theme.spacing(0, -1), - }, header: { - ...theme.typography.h6, - padding: theme.spacing(2, 4), + display: 'flex', + padding: theme.spacing(2), + '& > h1': { + ...theme.typography.h6, + margin: theme.spacing(0, 2), + }, + alignItems: 'center', + justifyContent: 'space-between', }, - spacer: { - flex: 1, + 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), + ':focus': { + // TODO + borderColor: fade(theme.palette.primary.main, 0.4), + backgroundColor: theme.palette.background.paper, + }, }, placeholderRow: { padding: theme.spacing(1), @@ -57,7 +65,7 @@ const useStyles = makeStyles(theme => ({ placeholderRowText: { flex: 1, }, - noBug: { + message: { ...theme.typography.h5, padding: theme.spacing(8), textAlign: 'center', @@ -68,6 +76,17 @@ const useStyles = makeStyles(theme => ({ 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` @@ -139,18 +158,47 @@ const Placeholder = ({ count }) => { 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 classes = useStyles(); const location = useLocation(); + const history = useHistory(); const params = new URLSearchParams(location.search); const query = params.get('q'); + + const [input, setInput] = useState(query); + + // 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'), @@ -204,11 +252,24 @@ function ListQuery() { }; } + // 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 =

Error: {JSON.stringify(error)}

; + content = ; } else { const bugs = data.defaultRepository.bugs; @@ -219,31 +280,42 @@ function ListQuery() { } } + const formSubmit = e => { + e.preventDefault(); + history.push(queryLocation(input)); + }; + return ( -
Issues
- - {/* TODO */} - - 123 open - - 456 closed -
- Author - Label - Sort - +
+

Issues

+
+ setInput(e.target.value)} + className={classes.search} + /> + + +
+ {content}
{loading ? 'Loading' : `Total: ${count}`}
- +
-- cgit From ead5bad7854bc2342e0998c8a45f62e9aace7887 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 30 Jan 2020 02:05:36 +0100 Subject: webui: implement issue list sort --- webui/src/__tests__/query.js | 4 +-- webui/src/list/Filter.js | 77 +++++++++++++++++++++++++++++++++++------ webui/src/list/FilterToolbar.js | 17 +++++++-- webui/src/list/ListQuery.js | 21 +++++++---- 4 files changed, 97 insertions(+), 22 deletions(-) (limited to 'webui/src') diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js index 1415af02..5f4b58eb 100644 --- a/webui/src/__tests__/query.js +++ b/webui/src/__tests__/query.js @@ -7,9 +7,9 @@ it('parses a simple query', () => { }); it('parses a query with multiple filters', () => { - expect(parse('foo:bar baz:foobar')).toEqual({ + expect(parse('foo:bar baz:foo-bar')).toEqual({ foo: ['bar'], - baz: ['foobar'], + baz: ['foo-bar'], }); }); diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js index c93b2d35..a6cf3633 100644 --- a/webui/src/list/Filter.js +++ b/webui/src/list/Filter.js @@ -1,13 +1,16 @@ -import React from 'react'; +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+):(\w+|(["'])(([^\3]|\\.)*)\3)+/g; + const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; let matches; while ((matches = re.exec(query)) !== null) { if (!params[matches[1]]) { @@ -58,20 +61,63 @@ const useStyles = makeStyles(theme => ({ ...theme.typography.body2, color: ({ active }) => (active ? '#333' : '#444'), padding: theme.spacing(0, 1), - fontWeight: ({ active }) => (active ? 500 : 400), + fontWeight: ({ active }) => (active ? 600 : 400), textDecoration: 'none', display: 'flex', - alignSelf: ({ end }) => (end ? 'flex-end' : 'auto'), background: 'none', border: 'none', }, + itemActive: { + fontWeight: 600, + }, icon: { paddingRight: theme.spacing(0.5), }, })); -function Filter({ active, to, children, icon: Icon, end, ...props }) { - const classes = useStyles({ active, end }); +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 = ( <> @@ -80,6 +126,19 @@ function Filter({ active, to, children, icon: Icon, end, ...props }) { ); + if (dropdown) { + return ( + + {content} + + ); + } + if (to) { return ( @@ -88,11 +147,7 @@ function Filter({ active, to, children, icon: Icon, end, ...props }) { ); } - return ( - - ); + return
{content}
; } export default Filter; diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.js index e6d6f4ed..9f5f14c5 100644 --- a/webui/src/list/FilterToolbar.js +++ b/webui/src/list/FilterToolbar.js @@ -32,7 +32,7 @@ function FilterToolbar({ query, queryLocation }) { }; // TODO: open/closed count - // TODO: author/label/sort filters + // TODO: author/label filters return ( Author Label - Sort + hasValue('sort', key)} + to={key => replaceParam('sort', key)} + > + Sort + ); } diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js index b6a29702..01113f6c 100644 --- a/webui/src/list/ListQuery.js +++ b/webui/src/list/ListQuery.js @@ -45,11 +45,13 @@ const useStyles = makeStyles(theme => ({ borderWidth: '1px', backgroundColor: fade(theme.palette.primary.main, 0.05), padding: theme.spacing(0, 1), - ':focus': { - // TODO - borderColor: fade(theme.palette.primary.main, 0.4), - backgroundColor: theme.palette.background.paper, - }, + 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), @@ -182,7 +184,6 @@ const Error = ({ error }) => { }; function ListQuery() { - const classes = useStyles(); const location = useLocation(); const history = useHistory(); const params = new URLSearchParams(location.search); @@ -190,6 +191,8 @@ function ListQuery() { const [input, setInput] = useState(query); + const classes = useStyles({ searching: !!input }); + // TODO is this the right way to do it? const lastQuery = useRef(); useEffect(() => { @@ -291,9 +294,13 @@ function ListQuery() {

Issues

setInput(e.target.value)} - className={classes.search} + classes={{ + root: classes.search, + focused: classes.searchFocused, + }} />