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/CurrentIdentity.tsx | 3 +- webui/src/Label.tsx | 3 +- webui/src/__tests__/query.js | 62 ------------ webui/src/__tests__/query.ts | 62 ++++++++++++ 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 +++++++++++++++++++++++ 9 files changed, 388 insertions(+), 344 deletions(-) delete mode 100644 webui/src/__tests__/query.js create mode 100644 webui/src/__tests__/query.ts 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') diff --git a/webui/src/CurrentIdentity.tsx b/webui/src/CurrentIdentity.tsx index 0a697cdd..07ff648c 100644 --- a/webui/src/CurrentIdentity.tsx +++ b/webui/src/CurrentIdentity.tsx @@ -14,8 +14,7 @@ const CurrentIdentity = () => { const classes = useStyles(); const { loading, error, data } = useCurrentIdentityQuery(); - if (error || loading || !data?.defaultRepository?.userIdentity) - return null; + if (error || loading || !data?.defaultRepository?.userIdentity) return null; const user = data.defaultRepository.userIdentity; return ( diff --git a/webui/src/Label.tsx b/webui/src/Label.tsx index e200f929..68c50b9d 100644 --- a/webui/src/Label.tsx +++ b/webui/src/Label.tsx @@ -18,7 +18,8 @@ const getTextColor = (background: string) => ? common.white // White on dark backgrounds : common.black; // And black on light ones -const _rgb = (color: Color) => 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; +const _rgb = (color: Color) => + 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; // Create a style object from the label RGB colors const createStyle = (color: Color) => ({ diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js deleted file mode 100644 index 5f4b58eb..00000000 --- a/webui/src/__tests__/query.js +++ /dev/null @@ -1,62 +0,0 @@ -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:foo-bar')).toEqual({ - foo: ['bar'], - baz: ['foo-bar'], - }); -}); - -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/__tests__/query.ts b/webui/src/__tests__/query.ts new file mode 100644 index 00000000..5f4b58eb --- /dev/null +++ b/webui/src/__tests__/query.ts @@ -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:foo-bar')).toEqual({ + foo: ['bar'], + baz: ['foo-bar'], + }); +}); + +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 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