diff --git a/webui/package-lock.json b/webui/package-lock.json index 5c948a4921dac2ca27beb6dd0b7fe9fddd9ae7e1..78551d9868d235beddde0dc89b83ec36ab24ccb0 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -127,6 +127,11 @@ "uuid": "^3.1.0" } }, + "@arrows/composition": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@arrows/composition/-/composition-1.2.2.tgz", + "integrity": "sha512-9fh1yHwrx32lundiB3SlZ/VwuStPB4QakPsSLrGJFH6rCXvdrd060ivAZ7/2vlqPnEjBkPRRXOcG1YOu19p2GQ==" + }, "@babel/code-frame": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", @@ -4536,9 +4541,9 @@ } }, "clsx": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.4.tgz", - "integrity": "sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.0.tgz", + "integrity": "sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==" }, "co": { "version": "4.6.0", diff --git a/webui/package.json b/webui/package.json index 031d411b3ceca101dee7266b01a1017a2a9c0ce3..03ac4da9cefffff3ad0b153ae064a376f0a4cba6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@apollo/react-hooks": "^3.1.3", + "@arrows/composition": "^1.2.2", "@material-ui/core": "^4.9.0", "@material-ui/icons": "^4.2.1", "@material-ui/lab": "^4.0.0-alpha.40", @@ -13,6 +14,7 @@ "@types/react-dom": "^16.9.5", "@types/react-router-dom": "^5.1.3", "apollo-boost": "^0.4.7", + "clsx": "^1.1.0", "graphql": "^14.6.0", "graphql.macro": "^1.4.2", "moment": "^2.24.0", diff --git a/webui/src/CurrentIdentity.tsx b/webui/src/CurrentIdentity.tsx index 0a697cddb0b9383982a177efa04398b35abc4d39..07ff648cef2b21bf61b91c9924becc419b4af3e5 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 e200f9295ebf5aee3716e2175310091704056b6c..68c50b9de9cf66ecab6b19414b3e33753b8da2a8 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.ts similarity index 100% rename from webui/src/__tests__/query.js rename to webui/src/__tests__/query.ts diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.tsx similarity index 57% rename from webui/src/list/Filter.js rename to webui/src/list/Filter.tsx index a6cf36335987b7d8dd28f627656adf111ae2c5ae..d009130608eab3b6828f5e9f4cf9ea2f9023899a 100644 --- a/webui/src/list/Filter.js +++ b/webui/src/list/Filter.tsx @@ -1,13 +1,18 @@ import React, { useState, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { makeStyles } from '@material-ui/styles'; +import { 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'; -function parse(query) { +export type Query = { [key: string]: Array }; + +function parse(query: string): Query { // TODO: extract the rest of the query? - const params = {}; + const params: Query = {}; // TODO: support escaping without quotes const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; @@ -29,7 +34,7 @@ function parse(query) { return params; } -function quote(value) { +function quote(value: string): string { const hasSingle = value.includes("'"); const hasDouble = value.includes('"'); const hasSpaces = value.includes(' '); @@ -49,19 +54,19 @@ function quote(value) { return `"${value}"`; } -function stringify(params) { - const parts = Object.entries(params).map(([key, values]) => { +function stringify(params: Query): string { + const parts: string[][] = Object.entries(params).map(([key, values]) => { return values.map(value => `${key}:${quote(value)}`); }); - return [].concat(...parts).join(' '); + return new Array().concat(...parts).join(' '); } const useStyles = makeStyles(theme => ({ element: { ...theme.typography.body2, - color: ({ active }) => (active ? '#333' : '#444'), + color: '#444', padding: theme.spacing(0, 1), - fontWeight: ({ active }) => (active ? 600 : 400), + fontWeight: 400, textDecoration: 'none', display: 'flex', background: 'none', @@ -69,21 +74,51 @@ const useStyles = makeStyles(theme => ({ }, itemActive: { fontWeight: 600, + color: '#333', }, icon: { paddingRight: theme.spacing(0.5), }, })); -function Dropdown({ children, dropdown, itemActive, to, ...props }) { +type DropdownTuple = [string, string]; + +type FilterDropdownProps = { + children: React.ReactNode; + dropdown: DropdownTuple[]; + itemActive: (key: string) => boolean; + icon?: React.ComponentType; + to: (key: string) => LocationDescriptor; +} & React.ButtonHTMLAttributes; + +function FilterDropdown({ + children, + dropdown, + itemActive, + icon: Icon, + to, + ...props +}: FilterDropdownProps) { const [open, setOpen] = useState(false); - const buttonRef = useRef(); - const classes = useStyles(); + const buttonRef = useRef(null); + const classes = useStyles({ active: false }); + + const content = ( + <> + {Icon && } +
{children}
+ + ); return ( <> - setOpen(false)} key={key} > @@ -116,8 +151,14 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) { ); } -function Filter({ active, to, children, icon: Icon, dropdown, ...props }) { - const classes = useStyles({ active }); +export type FilterProps = { + active: boolean; + to: LocationDescriptor; + icon?: React.ComponentType; + children: React.ReactNode; +}; +function Filter({ active, to, children, icon: Icon }: FilterProps) { + const classes = useStyles(); const content = ( <> @@ -126,29 +167,23 @@ function Filter({ active, to, children, icon: Icon, dropdown, ...props }) { ); - if (dropdown) { + if (to) { return ( - {content} - - ); - } - - if (to) { - return ( - - {content} ); } - return
{content}
; + return ( +
+ {content} +
+ ); } export default Filter; -export { parse, stringify, quote }; +export { parse, stringify, quote, FilterDropdown, Filter }; diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql new file mode 100644 index 0000000000000000000000000000000000000000..644a4eedbe04f43c817030659949f8c6cbfcef51 --- /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.tsx similarity index 58% rename from webui/src/list/FilterToolbar.js rename to webui/src/list/FilterToolbar.tsx index 4d0b52b185526276ffa2bcb09bdbc2a5505af390..2aaf7f84d4ca16ca62562c6bab0edd3f91109ee1 100644 --- a/webui/src/list/FilterToolbar.js +++ b/webui/src/list/FilterToolbar.tsx @@ -1,16 +1,19 @@ -import { makeStyles } from '@material-ui/styles'; -import { useQuery } from '@apollo/react-hooks'; -import gql from 'graphql-tag'; +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 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); +import { + FilterDropdown, + FilterProps, + Filter, + parse, + stringify, + Query, +} from './Filter'; +import { useBugCountQuery } from './FilterToolbar.generated'; const useStyles = makeStyles(theme => ({ toolbar: { @@ -25,25 +28,19 @@ const useStyles = makeStyles(theme => ({ }, })); -const BUG_COUNT_QUERY = gql` - query($query: String) { - defaultRepository { - bugs: allBugs(query: $query) { - totalCount - } - } - } -`; - // This prepends the filter text with a count -function CountingFilter({ query, children, ...props }) { - const { data, loading, error } = useQuery(BUG_COUNT_QUERY, { +type CountingFilterProps = { + query: string; + children: React.ReactNode; +} & FilterProps; +function CountingFilter({ query, children, ...props }: CountingFilterProps) { + const { data, loading, error } = useBugCountQuery({ variables: { query }, }); var prefix; if (loading) prefix = '...'; - else if (error) prefix = '???'; + else if (error || !data?.defaultRepository) prefix = '???'; // TODO: better prefixes & error handling else prefix = data.defaultRepository.bugs.totalCount; @@ -54,18 +51,26 @@ function CountingFilter({ query, children, ...props }) { ); } -function FilterToolbar({ query, queryLocation }) { +type Props = { + query: string; + queryLocation: (query: string) => LocationDescriptor; +}; +function FilterToolbar({ query, queryLocation }: Props) { const classes = useStyles(); - const params = parse(query); + const params: Query = parse(query); - const hasKey = key => params[key] && params[key].length > 0; - const hasValue = (key, value) => hasKey(key) && params[key].includes(value); - const loc = params => pipe(params, stringify, queryLocation); - const replaceParam = (key, value) => params => ({ + const hasKey = (key: string): boolean => + params[key] && params[key].length > 0; + const hasValue = (key: string, value: string): boolean => + hasKey(key) && params[key].includes(value); + const loc = pipe(stringify, queryLocation); + const replaceParam = (key: string, value: string) => ( + params: Query + ): Query => ({ ...params, [key]: [value], }); - const clearParam = key => params => ({ + const clearParam = (key: string) => (params: Query): Query => ({ ...params, [key]: [], }); @@ -76,12 +81,11 @@ function FilterToolbar({ query, queryLocation }) { open @@ -89,12 +93,11 @@ function FilterToolbar({ query, queryLocation }) { closed @@ -104,7 +107,7 @@ function FilterToolbar({ query, queryLocation }) { Author Label */} - hasValue('sort', key)} - to={key => pipe(params, replaceParam('sort', key), loc)} + to={key => pipe(replaceParam('sort', key), loc)(params)} > Sort - + ); }