aboutsummaryrefslogtreecommitdiffstats
path: root/webui/src/list
diff options
context:
space:
mode:
authorQuentin Gliech <quentingliech@gmail.com>2020-02-13 20:00:03 +0100
committerQuentin Gliech <quentingliech@gmail.com>2020-02-13 20:00:03 +0100
commitce6f6a984b374b189141116433ced80dfa0c2aae (patch)
treee6487b9b480e6b18767ae310b702b57e5cbef000 /webui/src/list
parent8b85780d76ad45675582f4478eedb026b7ac25e1 (diff)
downloadgit-bug-ce6f6a984b374b189141116433ced80dfa0c2aae.tar.gz
webui: move pages components
Diffstat (limited to 'webui/src/list')
-rw-r--r--webui/src/list/BugRow.graphql13
-rw-r--r--webui/src/list/BugRow.tsx112
-rw-r--r--webui/src/list/Filter.tsx189
-rw-r--r--webui/src/list/FilterToolbar.graphql7
-rw-r--r--webui/src/list/FilterToolbar.tsx128
-rw-r--r--webui/src/list/List.tsx21
-rw-r--r--webui/src/list/ListQuery.graphql37
-rw-r--r--webui/src/list/ListQuery.tsx314
8 files changed, 0 insertions, 821 deletions
diff --git a/webui/src/list/BugRow.graphql b/webui/src/list/BugRow.graphql
deleted file mode 100644
index c2966f10..00000000
--- a/webui/src/list/BugRow.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "../components/fragments.graphql"
-
-fragment BugRow on Bug {
- id
- humanId
- title
- status
- createdAt
- labels {
- ...Label
- }
- ...authored
-}
diff --git a/webui/src/list/BugRow.tsx b/webui/src/list/BugRow.tsx
deleted file mode 100644
index 181aec2e..00000000
--- a/webui/src/list/BugRow.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-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 { 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 '../components/Date';
-import Label from '../components/Label';
-import { Status } from '../gqlTypes';
-
-import { BugRowFragment } from './BugRow.generated';
-
-type OpenClosedProps = { className: string };
-const Open = ({ className }: OpenClosedProps) => (
- <Tooltip title="Open">
- <ErrorOutline htmlColor="#28a745" className={className} />
- </Tooltip>
-);
-
-const Closed = ({ className }: OpenClosedProps) => (
- <Tooltip title="Closed">
- <CheckCircleOutline htmlColor="#cb2431" className={className} />
- </Tooltip>
-);
-
-type StatusProps = { className: string; status: Status };
-const BugStatus: React.FC<StatusProps> = ({
- status,
- className,
-}: StatusProps) => {
- switch (status) {
- case 'OPEN':
- return <Open className={className} />;
- case 'CLOSED':
- return <Closed className={className} />;
- default:
- return <p>{'unknown status ' + status}</p>;
- }
-};
-
-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 (
- <TableRow hover>
- <TableCell className={classes.cell}>
- <BugStatus status={bug.status} className={classes.status} />
- <div className={classes.expand}>
- <Link to={'bug/' + bug.humanId}>
- <div className={classes.expand}>
- <span className={classes.title}>{bug.title}</span>
- {bug.labels.length > 0 && (
- <span className={classes.labels}>
- {bug.labels.map(l => (
- <Label key={l.name} label={l} />
- ))}
- </span>
- )}
- </div>
- </Link>
- <div className={classes.details}>
- {bug.humanId} opened
- <Date date={bug.createdAt} />
- by {bug.author.displayName}
- </div>
- </div>
- </TableCell>
- </TableRow>
- );
-}
-
-export default BugRow;
diff --git a/webui/src/list/Filter.tsx b/webui/src/list/Filter.tsx
deleted file mode 100644
index 30b52de8..00000000
--- a/webui/src/list/Filter.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-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<string> };
-
-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<string>().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<SvgIconProps>;
- to: (key: string) => LocationDescriptor;
-} & React.ButtonHTMLAttributes<HTMLButtonElement>;
-
-function FilterDropdown({
- children,
- dropdown,
- itemActive,
- icon: Icon,
- to,
- ...props
-}: FilterDropdownProps) {
- const [open, setOpen] = useState(false);
- const buttonRef = useRef<HTMLButtonElement>(null);
- const classes = useStyles({ active: false });
-
- const content = (
- <>
- {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
- <div>{children}</div>
- </>
- );
-
- return (
- <>
- <button
- ref={buttonRef}
- onClick={() => setOpen(!open)}
- className={classes.element}
- {...props}
- >
- {content}
- <ArrowDropDown fontSize="small" />
- </button>
- <Menu
- getContentAnchorEl={null}
- anchorOrigin={{
- vertical: 'bottom',
- horizontal: 'left',
- }}
- transformOrigin={{
- vertical: 'top',
- horizontal: 'left',
- }}
- open={open}
- onClose={() => setOpen(false)}
- anchorEl={buttonRef.current}
- >
- {dropdown.map(([key, value]) => (
- <MenuItem
- component={Link}
- to={to(key)}
- className={itemActive(key) ? classes.itemActive : undefined}
- onClick={() => setOpen(false)}
- key={key}
- >
- {value}
- </MenuItem>
- ))}
- </Menu>
- </>
- );
-}
-
-export type FilterProps = {
- active: boolean;
- to: LocationDescriptor;
- icon?: React.ComponentType<SvgIconProps>;
- children: React.ReactNode;
-};
-function Filter({ active, to, children, icon: Icon }: FilterProps) {
- const classes = useStyles();
-
- const content = (
- <>
- {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
- <div>{children}</div>
- </>
- );
-
- if (to) {
- return (
- <Link
- to={to}
- className={clsx(classes.element, active && classes.itemActive)}
- >
- {content}
- </Link>
- );
- }
-
- return (
- <div className={clsx(classes.element, active && classes.itemActive)}>
- {content}
- </div>
- );
-}
-
-export default Filter;
-export { parse, stringify, quote, FilterDropdown, Filter };
diff --git a/webui/src/list/FilterToolbar.graphql b/webui/src/list/FilterToolbar.graphql
deleted file mode 100644
index cd103f44..00000000
--- a/webui/src/list/FilterToolbar.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-query BugCount($query: String) {
- repository {
- bugs: allBugs(query: $query) {
- totalCount
- }
- }
-}
diff --git a/webui/src/list/FilterToolbar.tsx b/webui/src/list/FilterToolbar.tsx
deleted file mode 100644
index b95b10bc..00000000
--- a/webui/src/list/FilterToolbar.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { pipe } from '@arrows/composition';
-import Toolbar from '@material-ui/core/Toolbar';
-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,
- 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?.repository) prefix = '???';
- // TODO: better prefixes & error handling
- else prefix = data.repository.bugs.totalCount;
-
- return (
- <Filter {...props}>
- {prefix} {children}
- </Filter>
- );
-}
-
-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 (
- <Toolbar className={classes.toolbar}>
- <CountingFilter
- active={hasValue('status', 'open')}
- query={pipe(
- replaceParam('status', 'open'),
- clearParam('sort'),
- stringify
- )(params)}
- to={pipe(replaceParam('status', 'open'), loc)(params)}
- icon={ErrorOutline}
- >
- open
- </CountingFilter>
- <CountingFilter
- active={hasValue('status', 'closed')}
- query={pipe(
- replaceParam('status', 'closed'),
- clearParam('sort'),
- stringify
- )(params)}
- to={pipe(replaceParam('status', 'closed'), loc)(params)}
- icon={CheckCircleOutline}
- >
- closed
- </CountingFilter>
- <div className={classes.spacer} />
- {/*
- <Filter active={hasKey('author')}>Author</Filter>
- <Filter active={hasKey('label')}>Label</Filter>
- */}
- <FilterDropdown
- dropdown={[
- ['id', 'ID'],
- ['creation', 'Newest'],
- ['creation-asc', 'Oldest'],
- ['edit', 'Recently updated'],
- ['edit-asc', 'Least recently updated'],
- ]}
- itemActive={key => hasValue('sort', key)}
- to={key => pipe(replaceParam('sort', key), loc)(params)}
- >
- Sort
- </FilterDropdown>
- </Toolbar>
- );
-}
-
-export default FilterToolbar;
diff --git a/webui/src/list/List.tsx b/webui/src/list/List.tsx
deleted file mode 100644
index cebd13f2..00000000
--- a/webui/src/list/List.tsx
+++ /dev/null
@@ -1,21 +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';
-import { BugListFragment } from './ListQuery.generated';
-
-type Props = { bugs: BugListFragment };
-function List({ bugs }: Props) {
- return (
- <Table>
- <TableBody>
- {bugs.edges.map(({ cursor, node }) => (
- <BugRow bug={node} key={cursor} />
- ))}
- </TableBody>
- </Table>
- );
-}
-
-export default List;
diff --git a/webui/src/list/ListQuery.graphql b/webui/src/list/ListQuery.graphql
deleted file mode 100644
index ded60c8a..00000000
--- a/webui/src/list/ListQuery.graphql
+++ /dev/null
@@ -1,37 +0,0 @@
-#import "./BugRow.graphql"
-
-query ListBugs(
- $first: Int
- $last: Int
- $after: String
- $before: String
- $query: String
-) {
- repository {
- 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.tsx b/webui/src/list/ListQuery.tsx
deleted file mode 100644
index 84b72431..00000000
--- a/webui/src/list/ListQuery.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-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 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 FilterToolbar from './FilterToolbar';
-import List from './List';
-import { useListBugsQuery } from './ListQuery.generated';
-
-type StylesProps = { searching?: boolean };
-const useStyles = makeStyles<Theme, StylesProps>(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<PlaceholderProps> = ({
- count,
-}: PlaceholderProps) => {
- const classes = useStyles({});
- return (
- <>
- {new Array(count).fill(null).map((_, i) => (
- <div key={i} className={classes.placeholderRow}>
- <Skeleton
- className={classes.placeholderRowStatus}
- variant="circle"
- width={20}
- height={20}
- />
- <div className={classes.placeholderRowText}>
- <Skeleton height={22} />
- <Skeleton height={24} width="60%" />
- </div>
- </div>
- ))}
- </>
- );
-};
-
-// TODO: factor this out
-const NoBug = () => {
- const classes = useStyles({});
- return (
- <div className={classes.message}>
- <ErrorOutline fontSize="large" />
- <p>No results matched your search.</p>
- </div>
- );
-};
-
-type ErrorProps = { error: ApolloError };
-const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
- const classes = useStyles({});
- return (
- <div className={[classes.errorBox, classes.message].join(' ')}>
- <ErrorOutline fontSize="large" />
- <p>There was an error while fetching bug.</p>
- <p>
- <em>{error.message}</em>
- </p>
- <pre>
- <code>{JSON.stringify(error, null, 2)}</code>
- </pre>
- </div>
- );
-};
-
-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<string | null>(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?.repository?.bugs) {
- const bugs = data.repository.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 = <Placeholder count={10} />;
- } else if (error) {
- content = <Error error={error} />;
- } else if (data?.repository) {
- const bugs = data.repository.bugs;
-
- if (bugs.totalCount === 0) {
- content = <NoBug />;
- } else {
- content = <List bugs={bugs} />;
- }
- }
-
- const formSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- history.push(queryLocation(input));
- };
-
- return (
- <Paper className={classes.main}>
- <header className={classes.header}>
- <h1>Issues</h1>
- <form onSubmit={formSubmit}>
- <InputBase
- placeholder="Filter"
- value={input}
- onInput={(e: any) => setInput(e.target.value)}
- classes={{
- root: classes.search,
- focused: classes.searchFocused,
- }}
- />
- <button type="submit" hidden>
- Search
- </button>
- </form>
- </header>
- <FilterToolbar query={query} queryLocation={queryLocation} />
- {content}
- <div className={classes.pagination}>
- {previousPage ? (
- <IconButton component={Link} to={previousPage}>
- <KeyboardArrowLeft />
- </IconButton>
- ) : (
- <IconButton disabled>
- <KeyboardArrowLeft />
- </IconButton>
- )}
- <div>{loading ? 'Loading' : `Total: ${count}`}</div>
- {nextPage ? (
- <IconButton component={Link} to={nextPage}>
- <KeyboardArrowRight />
- </IconButton>
- ) : (
- <IconButton disabled>
- <KeyboardArrowRight />
- </IconButton>
- )}
- </div>
- </Paper>
- );
-}
-
-export default ListQuery;