diff options
Diffstat (limited to 'webui')
39 files changed, 1247 insertions, 198 deletions
diff --git a/webui/.eslintrc.js b/webui/.eslintrc.js index 2dfa7543..125fe801 100644 --- a/webui/.eslintrc.js +++ b/webui/.eslintrc.js @@ -38,4 +38,5 @@ module.exports = { settings: { 'import/internal-regex': '^src/', }, + ignorePatterns: ['**/*.generated.tsx'], }; diff --git a/webui/package-lock.json b/webui/package-lock.json index 7336b2bf..12dea8b9 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -12572,8 +12572,7 @@ "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "optional": true + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=" }, "gzip-size": { "version": "5.1.1", @@ -16116,10 +16115,9 @@ "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=" }, "node-notifier": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.0.tgz", - "integrity": "sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA==", - "optional": true, + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", + "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -16130,22 +16128,22 @@ }, "dependencies": { "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", - "optional": true + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "requires": { + "lru-cache": "^6.0.0" + } }, "uuid": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", - "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==", - "optional": true + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, "requires": { "isexe": "^2.0.0" } @@ -20221,8 +20219,7 @@ "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "optional": true + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==" }, "side-channel": { "version": "1.0.3", diff --git a/webui/public/logo-alpha-flat-outline.svg b/webui/public/logo-alpha-flat-outline.svg new file mode 100644 index 00000000..ea383f3c --- /dev/null +++ b/webui/public/logo-alpha-flat-outline.svg @@ -0,0 +1,21 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024"> + <defs> + <path id="b" d="M512.169916 1006.61442c-59.127025 0-118.268018-11.781213-172.799806-34.617561-54.406082-22.780496-104.066777-56.504427-145.315673-98.654977-20.922999-21.380714-39.670593-44.876809-55.890107-70.010056-17.752424-27.503453-33.154852-57.048981-43.3125626-88.22468-22.4244822-68.819718-19.4215151-144.704671 8.7854246-211.391554 26.454045-62.532915 73.778712-115.431419 133.345707-148.122094 7.140776-3.916598 14.438685-7.546956 21.869283-10.877111 1.581795-22.420951 7.259498-44.632458 15.35005-65.555887 8.062618-20.853613 18.635855-41.190599 32.313323-58.926992l-45.425115-45.365511c-25.284285 1.612716-50.236847-9.407515-66.183999-29.060318-15.870332-19.558553-21.457247-46.1579063-14.75644-70.4463972 6.473838-23.4786418 24.027228-43.0790839 46.615826-52.1759231 23.259028-9.3656253 50.268273-6.8837172 71.435699 6.5416257 24.04818 15.2579756 38.322749 42.9953065 36.534936 71.4447456l49.377858 49.310035c38.850014-22.675774 83.39519-34.258012 128.055596-34.331317 44.660406.073305 89.205582 11.655543 128.059088 34.331317l49.374366-49.310035c-1.787813-28.4494391 12.486757-56.18677 36.534936-71.4447456 21.167427-13.4253429 48.176671-15.907251 71.435699-6.5416257 22.588598 9.0968392 40.138497 28.6972813 46.615827 52.1759231 6.700807 24.2919816 1.113891 50.8878442-14.756441 70.4463972-15.947152 19.652803-40.899714 30.673034-66.18749 29.060318l-45.421623 45.365511c13.677467 17.736393 24.250705 38.073379 32.313322 58.926992 8.090552 20.923429 13.768255 43.134936 15.350051 65.555887 7.430598 3.330155 14.731998 6.960513 21.869282 10.877111 59.566996 32.690675 106.891662 85.59267 133.345707 148.125585 28.20694 66.683392 31.213399 142.568345 8.785425 211.388063-10.157711 31.175699-25.560139 60.721227-43.312563 88.22468-16.219514 25.133247-34.967108 48.629342-55.890106 70.010056-41.252388 42.15055-90.913084 75.874481-145.315674 98.654977-54.535279 22.836348-113.67278 34.617561-172.799806 34.617561"/> + <filter id="a" width="106%" height="105.3%" x="-3%" y="-2.2%" filterUnits="objectBoundingBox"> + <feOffset dy="4" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="8"/> + <feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/> + </filter> + </defs> + <g fill="none" fill-rule="evenodd"> + <g fill-rule="nonzero"> + <use fill="#000" filter="url(#a)" xlink:href="#b"/> + <use fill="#FFF" xlink:href="#b"/> + </g> + <path fill="#D0021B" d="M120.388511 676.726932c4.109875 19.495721 10.395155 38.464341 18.695216 56.570751 62.793439 135.897562 197.675548 231.648253 355.628126 238.256202V392.229046l-86.684487 86.608471c2.007798 5.044104 3.14264 10.531529 3.14264 16.291232 0 18.162262-10.988764 33.751856-26.67403 40.516889v210.012716c15.685266 6.768523 26.67403 22.354627 26.67403 40.520379 0 24.368778-19.760222 44.119321-44.133141 44.119321-24.376411 0-44.136633-19.750543-44.136633-44.119321 0-18.165752 10.988765-33.751856 26.677522-40.520379V537.23741l-62.37442 62.319981c2.007797 5.040612 3.14264 10.524547 3.14264 16.280759 0 24.365287-19.760222 44.119321-44.133141 44.119321-24.376411 0-44.136633-19.754034-44.136633-44.119321 0-24.368778 19.760222-44.122812 44.136633-44.122812 5.764998 0 11.257635 1.137978 16.306809 3.148638l63.519738-63.46145c-2.004306-5.040612-3.139148-10.521056-3.139148-16.273777 0-24.368778 19.760222-44.122812 44.136633-44.122812 5.761507 0 11.254143 1.137977 16.299826 3.145147l111.375162-111.273962v-3.413933c-58.285497 1.124015-115.188232 7.857631-170.205383 19.680729-143.245022 32.174047-234.4758615 173.520637-204.117959 317.583014"/> + <path fill="#161616" d="M512.169916 150.665029c49.758467 0 95.630535 16.518128 132.511162 44.321783l82.55366-82.440542c-6.274804-15.7815846-2.786474-34.1393273 8.844786-46.4999979 10.552287-11.2122209 26.565784-16.165565 41.619029-12.8772985 14.718031 3.2184519 27.058131 14.1025443 32.114289 28.2853753 5.188848 14.5388854 2.234766 31.1547541-7.633123 43.0162511-12.102656 14.549357-32.662505 19.788941-50.271765 12.769086l-81.048685 80.939528c29.792227 30.913894 50.669832 70.474323 58.411202 114.579682-55.240628-13.338075-111.706885-21.541288-168.455979-24.511898-57.475394-3.009008-115.233625-.684183-172.272541 6.988439-31.433384 4.227273-62.643291 10.081225-93.469098 17.523459 7.737878-44.105359 28.615484-83.665788 58.404219-114.579682l-81.048686-80.939528c-15.79002 6.297274-34.181447 2.810036-46.552973-8.824563-11.219225-10.548982-16.174121-26.560955-12.884824-41.6059961 3.21946-14.7134218 14.106961-27.0496574 28.294235-32.1077234 14.54344-5.1837322 31.164513-2.2305757 43.029725 7.6342239 14.539948 12.0918845 19.784665 32.6173697 12.790545 50.2106426l82.553661 82.440542c36.884118-27.803655 82.752695-44.321783 132.511161-44.321783"/> + <path fill="#D0021B" d="M700.196272 359.235741c-55.01715-11.823099-111.916394-18.556715-170.20189-19.68422v3.413933l111.37167 111.277452c5.049175-2.007169 10.538319-3.148637 16.303318-3.148637 24.376411 0 44.136632 19.757525 44.136632 44.122812 0 5.756211-1.134842 11.233165-3.139148 16.273777l63.516246 63.46494c5.049175-2.01066 10.541812-3.148637 16.310302-3.148637 24.372919 0 44.136633 19.754034 44.136633 44.119321 0 24.368778-19.763714 44.122812-44.136633 44.122812-24.376411 0-44.136633-19.754034-44.136633-44.122812 0-5.756212 1.134842-11.240147 3.146132-16.280759l-62.377912-62.319981v208.424434c15.688757 6.765033 26.681013 22.354627 26.681013 40.52038 0 24.365287-19.760221 44.119321-44.136632 44.119321-24.372919 0-44.133141-19.754034-44.133141-44.119321 0-18.165753 10.985272-33.755347 26.670538-40.52038V535.73746c-15.685266-6.765032-26.670538-22.354627-26.670538-40.520379 0-5.759703 1.134842-11.247129 3.14264-16.287741l-86.684487-86.611963v579.32484c157.949086-6.604459 292.831195-102.35864 355.628126-238.252711 8.296569-18.109901 14.581849-37.078522 18.691724-56.570751 30.357902-144.065868-60.872937-285.408967-204.11796-317.583014"/> + </g> + <animate attributeName="opacity" attributeType="XML" + dur="5s" from="0" to="1" fill="freeze"/> +</svg> diff --git a/webui/src/App.tsx b/webui/src/App.tsx index b9ade974..4c81913c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -5,6 +5,7 @@ import Layout from './components/Header'; import BugPage from './pages/bug'; import ListPage from './pages/list'; import NewBugPage from './pages/new/NewBugPage'; +import NotFoundPage from './pages/notfound/NotFoundPage'; export default function App() { return ( @@ -13,6 +14,7 @@ export default function App() { <Route path="/" exact component={ListPage} /> <Route path="/new" exact component={NewBugPage} /> <Route path="/bug/:id" exact component={BugPage} /> + <Route component={NotFoundPage} /> </Switch> </Layout> ); diff --git a/webui/src/components/BackToListButton.tsx b/webui/src/components/BackToListButton.tsx new file mode 100644 index 00000000..41e1d68a --- /dev/null +++ b/webui/src/components/BackToListButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import Button from '@material-ui/core/Button'; +import { makeStyles } from '@material-ui/core/styles'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; + +const useStyles = makeStyles((theme) => ({ + backButton: { + position: 'sticky', + top: '80px', + backgroundColor: theme.palette.primary.dark, + color: theme.palette.primary.contrastText, + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, +})); + +function BackToListButton() { + const classes = useStyles(); + + return ( + <Button + variant="contained" + className={classes.backButton} + aria-label="back to issue list" + component={Link} + to="/" + > + <ArrowBackIcon /> + Back to List + </Button> + ); +} + +export default BackToListButton; diff --git a/webui/src/components/BugTitleForm/BugTitleForm.tsx b/webui/src/components/BugTitleForm/BugTitleForm.tsx index c47eab31..529f23a2 100644 --- a/webui/src/components/BugTitleForm/BugTitleForm.tsx +++ b/webui/src/components/BugTitleForm/BugTitleForm.tsx @@ -1,12 +1,7 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; -import { - Button, - fade, - makeStyles, - TextField, - Typography, -} from '@material-ui/core'; +import { Button, makeStyles, Typography } from '@material-ui/core'; import { TimelineDocument } from '../../pages/bug/TimelineQuery.generated'; import IfLoggedIn from '../IfLoggedIn/IfLoggedIn'; @@ -14,6 +9,7 @@ import Author from 'src/components/Author'; import Date from 'src/components/Date'; import { BugFragment } from 'src/pages/bug/Bug.generated'; +import BugTitleInput from './BugTitleInput'; import { useSetTitleMutation } from './SetTitle.generated'; /** @@ -45,26 +41,16 @@ const useStyles = makeStyles((theme) => ({ marginLeft: theme.spacing(2), }, greenButton: { - marginLeft: '8px', - backgroundColor: '#2ea44fd9', - color: '#fff', + marginLeft: theme.spacing(1), + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, '&:hover': { - backgroundColor: '#2ea44f', + backgroundColor: theme.palette.success.dark, + color: theme.palette.primary.contrastText, }, }, - titleInput: { - 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, 0), - minWidth: 336, - transition: theme.transitions.create([ - 'width', - 'borderColor', - 'backgroundColor', - ]), + saveButton: { + marginRight: theme.spacing(1), }, })); @@ -85,7 +71,7 @@ function BugTitleForm({ bug }: Props) { function isFormValid() { if (issueTitleInput) { - return issueTitleInput.value.length > 0 ? true : false; + return issueTitleInput.value.length > 0; } else { return false; } @@ -122,11 +108,11 @@ function BugTitleForm({ bug }: Props) { function editableBugTitle() { return ( <form className={classes.headerTitle} onSubmit={submitNewTitle}> - <TextField + <BugTitleInput inputRef={(node) => { issueTitleInput = node; }} - className={classes.titleInput} + label="Title" variant="outlined" fullWidth margin="dense" @@ -135,6 +121,7 @@ function BugTitleForm({ bug }: Props) { /> <div className={classes.editButtonContainer}> <Button + className={classes.saveButton} size="small" variant="contained" type="submit" @@ -171,9 +158,10 @@ function BugTitleForm({ bug }: Props) { className={classes.greenButton} size="small" variant="contained" - href="/new" + component={Link} + to="/new" > - New issue + New bug </Button> </div> )} diff --git a/webui/src/components/BugTitleForm/BugTitleInput.tsx b/webui/src/components/BugTitleForm/BugTitleInput.tsx new file mode 100644 index 00000000..d2b060a2 --- /dev/null +++ b/webui/src/components/BugTitleForm/BugTitleInput.tsx @@ -0,0 +1,40 @@ +import { createStyles, fade, withStyles, TextField } from '@material-ui/core'; +import { Theme } from '@material-ui/core/styles'; + +const BugTitleInput = withStyles((theme: Theme) => + createStyles({ + root: { + '& .MuiInputLabel-outlined': { + color: theme.palette.text.primary, + }, + '& input:valid + fieldset': { + color: theme.palette.text.primary, + borderColor: theme.palette.divider, + borderWidth: 2, + }, + '& input:valid:hover + fieldset': { + color: theme.palette.text.primary, + borderColor: fade(theme.palette.divider, 0.3), + borderWidth: 2, + }, + '& input:valid:focus + fieldset': { + color: theme.palette.text.primary, + borderColor: theme.palette.divider, + }, + '& input:invalid + fieldset': { + borderColor: theme.palette.error.main, + borderWidth: 2, + }, + '& input:invalid:hover + fieldset': { + borderColor: theme.palette.error.main, + borderWidth: 2, + }, + '& input:invalid:focus + fieldset': { + borderColor: theme.palette.error.main, + borderWidth: 2, + }, + }, + }) +)(TextField); + +export default BugTitleInput; diff --git a/webui/src/components/CloseBugButton/CloseBugButton.tsx b/webui/src/components/CloseBugButton/CloseBugButton.tsx index 19f56cab..9f098483 100644 --- a/webui/src/components/CloseBugButton/CloseBugButton.tsx +++ b/webui/src/components/CloseBugButton/CloseBugButton.tsx @@ -1,12 +1,21 @@ import React from 'react'; import Button from '@material-ui/core/Button'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; import { BugFragment } from 'src/pages/bug/Bug.generated'; import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated'; import { useCloseBugMutation } from './CloseBug.generated'; +const useStyles = makeStyles((theme: Theme) => ({ + closeIssueIcon: { + color: theme.palette.secondary.dark, + paddingTop: '0.1rem', + }, +})); + interface Props { bug: BugFragment; disabled: boolean; @@ -14,6 +23,7 @@ interface Props { function CloseBugButton({ bug, disabled }: Props) { const [closeBug, { loading, error }] = useCloseBugMutation(); + const classes = useStyles(); function closeBugAction() { closeBug({ @@ -45,8 +55,9 @@ function CloseBugButton({ bug, disabled }: Props) { variant="contained" onClick={() => closeBugAction()} disabled={bug.status === 'CLOSED' || disabled} + startIcon={<ErrorOutlineIcon className={classes.closeIssueIcon} />} > - Close issue + Close bug </Button> </div> ); diff --git a/webui/src/components/CommentInput/CommentInput.tsx b/webui/src/components/CommentInput/CommentInput.tsx index 86cc7dbb..c574538e 100644 --- a/webui/src/components/CommentInput/CommentInput.tsx +++ b/webui/src/components/CommentInput/CommentInput.tsx @@ -51,6 +51,7 @@ const a11yProps = (index: number) => ({ type Props = { inputProps?: any; + inputText?: string; loading: boolean; onChange: (comment: string) => void; }; @@ -62,8 +63,8 @@ type Props = { * @param loading Disable input when component not ready yet * @param onChange Callback to return input value changes */ -function CommentInput({ inputProps, loading, onChange }: Props) { - const [input, setInput] = useState<string>(''); +function CommentInput({ inputProps, inputText, loading, onChange }: Props) { + const [input, setInput] = useState<string>(inputText ? inputText : ''); const [tab, setTab] = useState(0); const classes = useStyles(); diff --git a/webui/src/components/Content/PreTag.tsx b/webui/src/components/Content/PreTag.tsx index 5256ab12..8e352153 100644 --- a/webui/src/components/Content/PreTag.tsx +++ b/webui/src/components/Content/PreTag.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles({ const PreTag = (props: React.HTMLProps<HTMLPreElement>) => { const classes = useStyles(); - return <pre className={classes.tag} {...props}></pre>; + return <pre className={classes.tag} {...props} />; }; export default PreTag; diff --git a/webui/src/components/Header/Header.tsx b/webui/src/components/Header/Header.tsx index 3e39b5f3..3064f6e4 100644 --- a/webui/src/components/Header/Header.tsx +++ b/webui/src/components/Header/Header.tsx @@ -1,11 +1,15 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import AppBar from '@material-ui/core/AppBar'; +import Tab, { TabProps } from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; import Toolbar from '@material-ui/core/Toolbar'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; import { makeStyles } from '@material-ui/core/styles'; import CurrentIdentity from '../CurrentIdentity/CurrentIdentity'; +import { LightSwitch } from '../Themer'; const useStyles = makeStyles((theme) => ({ offset: { @@ -14,35 +18,99 @@ const useStyles = makeStyles((theme) => ({ filler: { flexGrow: 1, }, + appBar: { + backgroundColor: theme.palette.primary.dark, + color: theme.palette.primary.contrastText, + }, appTitle: { ...theme.typography.h6, - color: 'white', + color: theme.palette.primary.contrastText, textDecoration: 'none', display: 'flex', alignItems: 'center', }, + lightSwitch: { + padding: '0 20px', + }, logo: { height: '42px', marginRight: theme.spacing(2), }, })); +function a11yProps(index: any) { + return { + id: `nav-tab-${index}`, + 'aria-controls': `nav-tabpanel-${index}`, + }; +} + +const DisabledTabWithTooltip = (props: TabProps) => { + /*The span elements around disabled tabs are needed, as the tooltip + * won't be triggered by disabled elements. + * See: https://material-ui.com/components/tooltips/#disabled-elements + * This must be done in a wrapper component, otherwise the TabS component + * cannot pass it styles down to the Tab component. Resulting in (console) + * warnings. This wrapper acceps the passed down TabProps and pass it around + * the span element to the Tab component. + */ + const msg = `This feature doesn't exist yet. Come help us build it.`; + return ( + <Tooltip title={msg}> + <span> + <Tab disabled {...props} /> + </span> + </Tooltip> + ); +}; + function Header() { const classes = useStyles(); + const location = useLocation(); + const [selectedTab, setTab] = React.useState(location.pathname); + + const handleTabClick = ( + event: React.ChangeEvent<{}>, + newTabValue: string + ) => { + setTab(newTabValue); + }; return ( <> - <AppBar position="fixed" color="primary"> + <AppBar position="fixed" className={classes.appBar}> <Toolbar> <Link to="/" className={classes.appTitle}> - <img src="/logo.svg" className={classes.logo} alt="git-bug" /> + <img src="/logo.svg" className={classes.logo} alt="git-bug logo" /> git-bug </Link> - <div className={classes.filler}></div> + <div className={classes.filler} /> + <div className={classes.lightSwitch}> + <LightSwitch /> + </div> <CurrentIdentity /> </Toolbar> </AppBar> <div className={classes.offset} /> + <Tabs + centered + value={selectedTab} + onChange={handleTabClick} + aria-label="nav tabs" + > + <DisabledTabWithTooltip label="Code" value="/code" {...a11yProps(1)} /> + <Tab label="Bugs" value="/" component={Link} to="/" {...a11yProps(2)} /> + <DisabledTabWithTooltip + label="Pull Requests" + value="/pulls" + {...a11yProps(3)} + /> + <DisabledTabWithTooltip + label="Settings" + value="/settings" + {...a11yProps(4)} + /> + </Tabs> </> ); } diff --git a/webui/src/components/ReopenBugButton/ReopenBugButton.tsx b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx index 195ca512..e3e792fc 100644 --- a/webui/src/components/ReopenBugButton/ReopenBugButton.tsx +++ b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx @@ -46,7 +46,7 @@ function ReopenBugButton({ bug, disabled }: Props) { onClick={() => openBugAction()} disabled={bug.status === 'OPEN' || disabled} > - Reopen issue + Reopen bug </Button> </div> ); diff --git a/webui/src/components/Themer.tsx b/webui/src/components/Themer.tsx new file mode 100644 index 00000000..b4877974 --- /dev/null +++ b/webui/src/components/Themer.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useState } from 'react'; + +import { fade, ThemeProvider } from '@material-ui/core'; +import IconButton from '@material-ui/core/IconButton/IconButton'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; +import { Theme } from '@material-ui/core/styles'; +import { NightsStayRounded, WbSunnyRounded } from '@material-ui/icons'; +import { makeStyles } from '@material-ui/styles'; + +const ThemeContext = createContext({ + toggleMode: () => {}, + mode: '', +}); + +const useStyles = makeStyles((theme: Theme) => ({ + iconButton: { + color: fade(theme.palette.primary.contrastText, 0.5), + }, +})); + +const LightSwitch = () => { + const { mode, toggleMode } = useContext(ThemeContext); + const nextMode = mode === 'light' ? 'dark' : 'light'; + const description = `Switch to ${nextMode} theme`; + const classes = useStyles(); + + return ( + <Tooltip title={description}> + <IconButton + onClick={toggleMode} + aria-label={description} + className={classes.iconButton} + > + {mode === 'light' ? <WbSunnyRounded /> : <NightsStayRounded />} + </IconButton> + </Tooltip> + ); +}; + +type Props = { + children: React.ReactNode; + lightTheme: Theme; + darkTheme: Theme; +}; +const Themer = ({ children, lightTheme, darkTheme }: Props) => { + const savedMode = localStorage.getItem('themeMode'); + const preferedMode = savedMode != null ? savedMode : 'light'; + const [mode, setMode] = useState(preferedMode); + + const toggleMode = () => { + const preferedMode = mode === 'light' ? 'dark' : 'light'; + localStorage.setItem('themeMode', preferedMode); + setMode(preferedMode); + }; + + const preferedTheme = mode === 'dark' ? darkTheme : lightTheme; + + return ( + <ThemeContext.Provider value={{ toggleMode: toggleMode, mode: mode }}> + <ThemeProvider theme={preferedTheme}>{children}</ThemeProvider> + </ThemeContext.Provider> + ); +}; + +export { Themer as default, LightSwitch }; diff --git a/webui/src/index.tsx b/webui/src/index.tsx index f07b869d..d3591e1a 100644 --- a/webui/src/index.tsx +++ b/webui/src/index.tsx @@ -3,18 +3,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; -import ThemeProvider from '@material-ui/styles/ThemeProvider'; - import App from './App'; import apolloClient from './apollo'; -import theme from './theme'; +import Themer from './components/Themer'; +import { defaultLightTheme, defaultDarkTheme } from './themes/index'; ReactDOM.render( <ApolloProvider client={apolloClient}> <BrowserRouter> - <ThemeProvider theme={theme}> + <Themer lightTheme={defaultLightTheme} darkTheme={defaultDarkTheme}> <App /> - </ThemeProvider> + </Themer> </BrowserRouter> </ApolloProvider>, document.getElementById('root') diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx index d85c5296..25281f96 100644 --- a/webui/src/pages/bug/Bug.tsx +++ b/webui/src/pages/bug/Bug.tsx @@ -18,11 +18,17 @@ const useStyles = makeStyles((theme) => ({ maxWidth: 1000, margin: 'auto', marginTop: theme.spacing(4), - overflow: 'hidden', }, header: { - marginLeft: theme.spacing(3) + 40, marginRight: theme.spacing(2), + marginLeft: theme.spacing(3) + 40, + }, + title: { + ...theme.typography.h5, + }, + id: { + ...theme.typography.subtitle1, + marginLeft: theme.spacing(1), }, container: { display: 'flex', @@ -36,11 +42,11 @@ const useStyles = makeStyles((theme) => ({ marginRight: theme.spacing(2), minWidth: 400, }, - sidebar: { + rightSidebar: { marginTop: theme.spacing(2), flex: '0 0 200px', }, - sidebarTitle: { + rightSidebarTitle: { fontWeight: 'bold', }, labelList: { @@ -59,6 +65,7 @@ const useStyles = makeStyles((theme) => ({ ...theme.typography.body2, }, commentForm: { + marginTop: theme.spacing(2), marginLeft: 48, }, })); @@ -75,10 +82,9 @@ function Bug({ bug }: Props) { <div className={classes.header}> <BugTitleForm bug={bug} /> </div> - <div className={classes.container}> <div className={classes.timeline}> - <TimelineQuery id={bug.id} /> + <TimelineQuery bug={bug} /> <IfLoggedIn> {() => ( <div className={classes.commentForm}> @@ -87,8 +93,8 @@ function Bug({ bug }: Props) { )} </IfLoggedIn> </div> - <div className={classes.sidebar}> - <span className={classes.sidebarTitle}>Labels</span> + <div className={classes.rightSidebar}> + <span className={classes.rightSidebarTitle}>Labels</span> <ul className={classes.labelList}> {bug.labels.length === 0 && ( <span className={classes.noLabel}>None yet</span> diff --git a/webui/src/pages/bug/BugQuery.tsx b/webui/src/pages/bug/BugQuery.tsx index 2a70a2f8..5d459c42 100644 --- a/webui/src/pages/bug/BugQuery.tsx +++ b/webui/src/pages/bug/BugQuery.tsx @@ -3,6 +3,8 @@ import { RouteComponentProps } from 'react-router-dom'; import CircularProgress from '@material-ui/core/CircularProgress'; +import NotFoundPage from '../notfound/NotFoundPage'; + import Bug from './Bug'; import { useGetBugQuery } from './BugQuery.generated'; @@ -15,8 +17,8 @@ const BugQuery: React.FC<Props> = ({ match }: Props) => { variables: { id: match.params.id }, }); if (loading) return <CircularProgress />; + if (!data?.repository?.bug) return <NotFoundPage />; if (error) return <p>Error: {error}</p>; - if (!data?.repository?.bug) return <p>404.</p>; return <Bug bug={data.repository.bug} />; }; diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx index 0b97e133..e70348a6 100644 --- a/webui/src/pages/bug/CommentForm.tsx +++ b/webui/src/pages/bug/CommentForm.tsx @@ -15,7 +15,6 @@ import { TimelineDocument } from './TimelineQuery.generated'; type StyleProps = { loading: boolean }; const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ container: { - margin: theme.spacing(2, 0), padding: theme.spacing(0, 2, 2, 2), }, textarea: {}, @@ -28,14 +27,16 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ }, actions: { display: 'flex', + gap: '1em', justifyContent: 'flex-end', }, greenButton: { marginLeft: '8px', - backgroundColor: '#2ea44fd9', - color: '#fff', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, '&:hover': { - backgroundColor: '#2ea44f', + backgroundColor: theme.palette.success.dark, + color: theme.palette.primary.contrastText, }, }, })); diff --git a/webui/src/pages/bug/EditCommentForm.graphql b/webui/src/pages/bug/EditCommentForm.graphql new file mode 100644 index 00000000..4765b75c --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.graphql @@ -0,0 +1,16 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +mutation EditComment($input: EditCommentInput!) { + editComment(input: $input) { + bug { + id + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/EditCommentForm.tsx b/webui/src/pages/bug/EditCommentForm.tsx new file mode 100644 index 00000000..8fa659b3 --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.tsx @@ -0,0 +1,123 @@ +import React, { useState, useRef } from 'react'; + +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +import CommentInput from '../../components/CommentInput/CommentInput'; + +import { BugFragment } from './Bug.generated'; +import { useEditCommentMutation } from './EditCommentForm.generated'; +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; + +type StyleProps = { loading: boolean }; +const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ + container: { + padding: theme.spacing(0, 2, 2, 2), + }, + textarea: {}, + tabContent: { + margin: theme.spacing(2, 0), + }, + preview: { + borderBottom: `solid 3px ${theme.palette.grey['200']}`, + minHeight: '5rem', + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, + greenButton: { + marginLeft: '8px', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, + '&:hover': { + backgroundColor: theme.palette.success.dark, + color: theme.palette.success.contrastText, + }, + }, +})); + +type Props = { + bug: BugFragment; + comment: AddCommentFragment | CreateFragment; + onCancel?: () => void; + onPostSubmit?: (comments: any) => void; +}; + +function EditCommentForm({ bug, comment, onCancel, onPostSubmit }: Props) { + const [editComment, { loading }] = useEditCommentMutation(); + const [message, setMessage] = useState<string>(comment.message); + const [inputProp, setInputProp] = useState<any>(''); + const classes = useStyles({ loading }); + const form = useRef<HTMLFormElement>(null); + + const submit = () => { + editComment({ + variables: { + input: { + prefix: bug.id, + message: message, + target: comment.id, + }, + }, + }).then((result) => { + const comments = result.data?.editComment.bug.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const modifiedComment = comments.find((elem) => elem.id === comment.id); + if (onPostSubmit) onPostSubmit(modifiedComment); + }); + resetForm(); + }; + + function resetForm() { + setInputProp({ + value: '', + }); + } + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (message.length > 0) submit(); + }; + + function getCancelButton() { + return ( + <Button onClick={onCancel} variant="contained"> + Cancel + </Button> + ); + } + + return ( + <Paper className={classes.container}> + <form onSubmit={handleSubmit} ref={form}> + <CommentInput + inputProps={inputProp} + loading={loading} + onChange={(message: string) => setMessage(message)} + inputText={comment.message} + /> + <div className={classes.actions}> + {onCancel && getCancelButton()} + <Button + className={classes.greenButton} + variant="contained" + color="primary" + type="submit" + disabled={loading || message.length === 0} + > + Update Comment + </Button> + </div> + </form> + </Paper> + ); +} + +export default EditCommentForm; diff --git a/webui/src/pages/bug/Message.tsx b/webui/src/pages/bug/Message.tsx index 91549483..2f4cbc59 100644 --- a/webui/src/pages/bug/Message.tsx +++ b/webui/src/pages/bug/Message.tsx @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; +import IconButton from '@material-ui/core/IconButton'; import Paper from '@material-ui/core/Paper'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; import { makeStyles } from '@material-ui/core/styles'; +import EditIcon from '@material-ui/icons/Edit'; +import HistoryIcon from '@material-ui/icons/History'; import Author, { Avatar } from 'src/components/Author'; import Content from 'src/components/Content'; import Date from 'src/components/Date'; +import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn'; +import { BugFragment } from './Bug.generated'; +import EditCommentForm from './EditCommentForm'; import { AddCommentFragment } from './MessageCommentFragment.generated'; import { CreateFragment } from './MessageCreateFragment.generated'; +import MessageHistoryDialog from './MessageHistoryDialog'; const useStyles = makeStyles((theme) => ({ author: { @@ -27,11 +35,13 @@ const useStyles = makeStyles((theme) => ({ }, header: { ...theme.typography.body1, - color: '#444', padding: '0.5rem 1rem', - borderBottom: '1px solid #ddd', + borderBottom: `1px solid ${theme.palette.divider}`, display: 'flex', - backgroundColor: '#e2f1ff', + borderTopRightRadius: theme.shape.borderRadius, + borderTopLeftRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.info.main, + color: theme.palette.info.contrastText, }, title: { flex: 1, @@ -47,32 +57,135 @@ const useStyles = makeStyles((theme) => ({ }, body: { ...theme.typography.body2, - padding: '0 1rem', + padding: '0.5rem', + }, + headerActions: { + color: theme.palette.info.contrastText, + padding: '0rem', + marginLeft: theme.spacing(1), + fontSize: '0.75rem', + '&:hover': { + backgroundColor: 'inherit', + }, }, })); +type HistBtnProps = { + bugId: string; + commentId: string; +}; +function HistoryMenuToggleButton({ bugId, commentId }: HistBtnProps) { + const classes = useStyles(); + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + <div> + <IconButton + aria-label="more" + aria-controls="long-menu" + aria-haspopup="true" + onClick={handleClickOpen} + className={classes.headerActions} + > + <HistoryIcon /> + </IconButton> + { + // Render CustomizedDialogs on open to prevent fetching the history + // before opening the history menu. + open && ( + <MessageHistoryDialog + bugId={bugId} + commentId={commentId} + open={open} + onClose={handleClose} + /> + ) + } + </div> + ); +} + type Props = { + bug: BugFragment; op: AddCommentFragment | CreateFragment; }; - -function Message({ op }: Props) { +function Message({ bug, op }: Props) { const classes = useStyles(); - return ( - <article className={classes.container}> - <Avatar author={op.author} className={classes.avatar} /> + const [editMode, switchToEditMode] = useState(false); + const [comment, setComment] = useState(op); + + const editComment = (id: String) => { + switchToEditMode(true); + }; + + function readMessageView() { + return ( <Paper elevation={1} className={classes.bubble}> <header className={classes.header}> <div className={classes.title}> - <Author className={classes.author} author={op.author} /> + <Author className={classes.author} author={comment.author} /> <span> commented </span> - <Date date={op.createdAt} /> + <Date date={comment.createdAt} /> </div> - {op.edited && <div className={classes.tag}>Edited</div>} + {comment.edited && ( + <HistoryMenuToggleButton bugId={bug.id} commentId={comment.id} /> + )} + <IfLoggedIn> + {() => ( + <Tooltip title="Edit Message" placement="top" arrow={true}> + <IconButton + disableRipple + className={classes.headerActions} + aria-label="edit message" + onClick={() => editComment(comment.id)} + > + <EditIcon /> + </IconButton> + </Tooltip> + )} + </IfLoggedIn> </header> <section className={classes.body}> - <Content markdown={op.message} /> + <Content markdown={comment.message} /> </section> </Paper> + ); + } + + function editMessageView() { + const cancelEdition = () => { + switchToEditMode(false); + }; + + const onPostSubmit = (comment: AddCommentFragment | CreateFragment) => { + setComment(comment); + switchToEditMode(false); + }; + + return ( + <div className={classes.bubble}> + <EditCommentForm + bug={bug} + onCancel={cancelEdition} + onPostSubmit={onPostSubmit} + comment={comment} + /> + </div> + ); + } + + return ( + <article className={classes.container}> + <Avatar author={comment.author} className={classes.avatar} /> + {editMode ? editMessageView() : readMessageView()} </article> ); } diff --git a/webui/src/pages/bug/MessageCommentFragment.graphql b/webui/src/pages/bug/MessageCommentFragment.graphql index 00f8342d..c852b4b0 100644 --- a/webui/src/pages/bug/MessageCommentFragment.graphql +++ b/webui/src/pages/bug/MessageCommentFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment AddComment on AddCommentTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageCreateFragment.graphql b/webui/src/pages/bug/MessageCreateFragment.graphql index 4cae819d..1f4647b6 100644 --- a/webui/src/pages/bug/MessageCreateFragment.graphql +++ b/webui/src/pages/bug/MessageCreateFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment Create on CreateTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageHistory.graphql b/webui/src/pages/bug/MessageHistory.graphql new file mode 100644 index 00000000..e90eb459 --- /dev/null +++ b/webui/src/pages/bug/MessageHistory.graphql @@ -0,0 +1,15 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +query MessageHistory($bugIdPrefix: String!) { + repository { + bug(prefix: $bugIdPrefix) { + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/MessageHistoryDialog.tsx b/webui/src/pages/bug/MessageHistoryDialog.tsx new file mode 100644 index 00000000..0ed33642 --- /dev/null +++ b/webui/src/pages/bug/MessageHistoryDialog.tsx @@ -0,0 +1,235 @@ +import moment from 'moment'; +import React from 'react'; +import Moment from 'react-moment'; + +import MuiAccordion from '@material-ui/core/Accordion'; +import MuiAccordionDetails from '@material-ui/core/AccordionDetails'; +import MuiAccordionSummary from '@material-ui/core/AccordionSummary'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import Grid from '@material-ui/core/Grid'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; +import Typography from '@material-ui/core/Typography'; +import { + createStyles, + Theme, + withStyles, + WithStyles, +} from '@material-ui/core/styles'; +import CloseIcon from '@material-ui/icons/Close'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; +import { useMessageHistoryQuery } from './MessageHistory.generated'; + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + }, + }); + +export interface DialogTitleProps extends WithStyles<typeof styles> { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + <MuiDialogTitle disableTypography className={classes.root} {...other}> + <Typography variant="h6">{children}</Typography> + {onClose ? ( + <IconButton + aria-label="close" + className={classes.closeButton} + onClick={onClose} + > + <CloseIcon /> + </IconButton> + ) : null} + </MuiDialogTitle> + ); +}); + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const Accordion = withStyles({ + root: { + border: '1px solid rgba(0, 0, 0, .125)', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto', + }, + }, + expanded: {}, +})(MuiAccordion); + +const AccordionSummary = withStyles((theme) => ({ + root: { + backgroundColor: theme.palette.primary.light, + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + borderBottomColor: theme.palette.divider, + marginBottom: -1, + minHeight: 56, + '&$expanded': { + minHeight: 56, + }, + }, + content: { + '&$expanded': { + margin: '12px 0', + }, + }, + expanded: {}, +}))(MuiAccordionSummary); + +const AccordionDetails = withStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiAccordionDetails); + +type Props = { + bugId: string; + commentId: string; + open: boolean; + onClose: () => void; +}; +function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) { + const [expanded, setExpanded] = React.useState<string | false>('panel0'); + + const { loading, error, data } = useMessageHistoryQuery({ + variables: { bugIdPrefix: bugId }, + }); + if (loading) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Loading... + </DialogTitle> + <DialogContent dividers> + <Grid container justify="center"> + <CircularProgress /> + </Grid> + </DialogContent> + </Dialog> + ); + } + if (error) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Something went wrong... + </DialogTitle> + <DialogContent dividers> + <p>Error: {error}</p> + </DialogContent> + </Dialog> + ); + } + + const comments = data?.repository?.bug?.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const comment = comments.find((elem) => elem.id === commentId); + // Sort by most recent edit. Must create a copy of constant history as + // reverse() modifies inplace. + const history = comment?.history.slice().reverse(); + const editCount = history?.length === undefined ? 0 : history?.length - 1; + + const handleChange = (panel: string) => ( + event: React.ChangeEvent<{}>, + newExpanded: boolean + ) => { + setExpanded(newExpanded ? panel : false); + }; + + const getSummary = (index: number, date: Date) => { + const desc = + index === editCount ? 'Created ' : `#${editCount - index} • Edited `; + const mostRecent = index === 0 ? ' (most recent)' : ''; + return ( + <> + <Tooltip title={moment(date).format('LLLL')}> + <span> + {desc} + <Moment date={date} format="on ll" /> + {mostRecent} + </span> + </Tooltip> + </> + ); + }; + + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="md" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + {`Edited ${editCount} ${editCount > 1 ? 'times' : 'time'}.`} + </DialogTitle> + <DialogContent dividers> + {history?.map((edit, index) => ( + <Accordion + square + expanded={expanded === 'panel' + index} + onChange={handleChange('panel' + index)} + > + <AccordionSummary + expandIcon={<ExpandMoreIcon />} + aria-controls="panel1d-content" + id="panel1d-header" + > + <Typography>{getSummary(index, edit.date)}</Typography> + </AccordionSummary> + <AccordionDetails>{edit.message}</AccordionDetails> + </Accordion> + ))} + </DialogContent> + </Dialog> + ); +} + +export default MessageHistoryDialog; diff --git a/webui/src/pages/bug/Timeline.tsx b/webui/src/pages/bug/Timeline.tsx index 6e1d242e..60459a53 100644 --- a/webui/src/pages/bug/Timeline.tsx +++ b/webui/src/pages/bug/Timeline.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; +import { BugFragment } from './Bug.generated'; import LabelChange from './LabelChange'; import Message from './Message'; import SetStatus from './SetStatus'; @@ -18,9 +19,10 @@ const useStyles = makeStyles((theme) => ({ type Props = { ops: Array<TimelineItemFragment>; + bug: BugFragment; }; -function Timeline({ ops }: Props) { +function Timeline({ bug, ops }: Props) { const classes = useStyles(); return ( @@ -28,9 +30,9 @@ function Timeline({ ops }: Props) { {ops.map((op, index) => { switch (op.__typename) { case 'CreateTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'AddCommentTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'LabelChangeTimelineItem': return <LabelChange key={index} op={op} />; case 'SetTitleTimelineItem': diff --git a/webui/src/pages/bug/TimelineQuery.tsx b/webui/src/pages/bug/TimelineQuery.tsx index 74eed52b..d66c665b 100644 --- a/webui/src/pages/bug/TimelineQuery.tsx +++ b/webui/src/pages/bug/TimelineQuery.tsx @@ -2,17 +2,18 @@ import React from 'react'; import CircularProgress from '@material-ui/core/CircularProgress'; +import { BugFragment } from './Bug.generated'; import Timeline from './Timeline'; import { useTimelineQuery } from './TimelineQuery.generated'; type Props = { - id: string; + bug: BugFragment; }; -const TimelineQuery = ({ id }: Props) => { +const TimelineQuery = ({ bug }: Props) => { const { loading, error, data } = useTimelineQuery({ variables: { - id, + id: bug.id, first: 100, }, }); @@ -25,7 +26,7 @@ const TimelineQuery = ({ id }: Props) => { return null; } - return <Timeline ops={nodes} />; + return <Timeline ops={nodes} bug={bug} />; }; export default TimelineQuery; diff --git a/webui/src/pages/list/BugRow.graphql b/webui/src/pages/list/BugRow.graphql index 547c09d8..e4e2760c 100644 --- a/webui/src/pages/list/BugRow.graphql +++ b/webui/src/pages/list/BugRow.graphql @@ -9,5 +9,8 @@ fragment BugRow on Bug { labels { ...Label } + comments { + totalCount + } ...authored } diff --git a/webui/src/pages/list/BugRow.tsx b/webui/src/pages/list/BugRow.tsx index 8d8fb5cb..1f5d22aa 100644 --- a/webui/src/pages/list/BugRow.tsx +++ b/webui/src/pages/list/BugRow.tsx @@ -6,6 +6,7 @@ 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 CommentOutlinedIcon from '@material-ui/icons/CommentOutlined'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; import Date from 'src/components/Date'; @@ -74,6 +75,13 @@ const useStyles = makeStyles((theme) => ({ display: 'inline-block', }, }, + commentCount: { + fontSize: '1rem', + marginLeft: theme.spacing(0.5), + }, + commentCountCell: { + display: 'inline-flex', + }, })); type Props = { @@ -82,6 +90,8 @@ type Props = { function BugRow({ bug }: Props) { const classes = useStyles(); + // Subtract 1 from totalCount as 1 comment is the bug description + const commentCount = bug.comments.totalCount - 1; return ( <TableRow hover> <TableCell className={classes.cell}> @@ -105,6 +115,12 @@ function BugRow({ bug }: Props) { by {bug.author.displayName} </div> </div> + {commentCount > 0 && ( + <span className={classes.commentCountCell}> + <CommentOutlinedIcon aria-label="Comment count" /> + <span className={classes.commentCount}>{commentCount}</span> + </span> + )} </TableCell> </TableRow> ); diff --git a/webui/src/pages/list/Filter.tsx b/webui/src/pages/list/Filter.tsx index 5c4a3d17..2e99eedf 100644 --- a/webui/src/pages/list/Filter.tsx +++ b/webui/src/pages/list/Filter.tsx @@ -1,14 +1,33 @@ import clsx from 'clsx'; import { LocationDescriptor } from 'history'; -import React, { useState, useRef } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; 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 TextField from '@material-ui/core/TextField'; +import { makeStyles, withStyles } from '@material-ui/core/styles'; import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; +const CustomTextField = withStyles((theme) => ({ + root: { + margin: '0 8px 12px 8px', + '& label.Mui-focused': { + margin: '0 2px', + color: theme.palette.text.secondary, + }, + '& .MuiInput-underline::before': { + borderBottomColor: theme.palette.divider, + }, + '& .MuiInput-underline::after': { + borderBottomColor: theme.palette.divider, + }, + }, +}))(TextField); + +const ITEM_HEIGHT = 48; + export type Query = { [key: string]: string[] }; function parse(query: string): Query { @@ -65,7 +84,7 @@ function stringify(params: Query): string { const useStyles = makeStyles((theme) => ({ element: { ...theme.typography.body2, - color: '#444', + color: theme.palette.text.secondary, padding: theme.spacing(0, 1), fontWeight: 400, textDecoration: 'none', @@ -75,7 +94,7 @@ const useStyles = makeStyles((theme) => ({ }, itemActive: { fontWeight: 600, - color: '#333', + color: theme.palette.text.primary, }, icon: { paddingRight: theme.spacing(0.5), @@ -90,6 +109,7 @@ type FilterDropdownProps = { itemActive: (key: string) => boolean; icon?: React.ComponentType<SvgIconProps>; to: (key: string) => LocationDescriptor; + hasFilter?: boolean; } & React.ButtonHTMLAttributes<HTMLButtonElement>; function FilterDropdown({ @@ -98,12 +118,19 @@ function FilterDropdown({ itemActive, icon: Icon, to, + hasFilter, ...props }: FilterDropdownProps) { const [open, setOpen] = useState(false); + const [filter, setFilter] = useState<string>(''); const buttonRef = useRef<HTMLButtonElement>(null); + const searchRef = useRef<HTMLButtonElement>(null); const classes = useStyles({ active: false }); + useEffect(() => { + searchRef && searchRef.current && searchRef.current.focus(); + }, [filter]); + const content = ( <> {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />} @@ -124,6 +151,7 @@ function FilterDropdown({ </button> <Menu getContentAnchorEl={null} + ref={searchRef} anchorOrigin={{ vertical: 'bottom', horizontal: 'left', @@ -135,18 +163,37 @@ function FilterDropdown({ open={open} onClose={() => setOpen(false)} anchorEl={buttonRef.current} + PaperProps={{ + style: { + maxHeight: ITEM_HEIGHT * 4.5, + width: '25ch', + }, + }} > - {dropdown.map(([key, value]) => ( - <MenuItem - component={Link} - to={to(key)} - className={itemActive(key) ? classes.itemActive : undefined} - onClick={() => setOpen(false)} - key={key} - > - {value} - </MenuItem> - ))} + {hasFilter && ( + <CustomTextField + onChange={(e) => { + const { value } = e.target; + setFilter(value); + }} + onKeyDown={(e) => e.stopPropagation()} + value={filter} + label={`Filter ${children}`} + /> + )} + {dropdown + .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase())) + .map(([key, value]) => ( + <MenuItem + component={Link} + to={to(key)} + className={itemActive(key) ? classes.itemActive : undefined} + onClick={() => setOpen(false)} + key={key} + > + {value} + </MenuItem> + ))} </Menu> </> ); @@ -158,6 +205,7 @@ export type FilterProps = { icon?: React.ComponentType<SvgIconProps>; children: React.ReactNode; }; + function Filter({ active, to, children, icon: Icon }: FilterProps) { const classes = useStyles(); diff --git a/webui/src/pages/list/FilterToolbar.tsx b/webui/src/pages/list/FilterToolbar.tsx index 21626416..979bf530 100644 --- a/webui/src/pages/list/FilterToolbar.tsx +++ b/webui/src/pages/list/FilterToolbar.tsx @@ -8,19 +8,21 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; import { + Filter, FilterDropdown, FilterProps, - Filter, parse, - stringify, Query, + stringify, } from './Filter'; import { useBugCountQuery } from './FilterToolbar.generated'; +import { useListIdentitiesQuery } from './ListIdentities.generated'; +import { useListLabelsQuery } from './ListLabels.generated'; const useStyles = makeStyles((theme) => ({ toolbar: { - backgroundColor: theme.palette.grey['100'], - borderColor: theme.palette.grey['300'], + backgroundColor: theme.palette.primary.light, + borderColor: theme.palette.divider, borderWidth: '1px 0', borderStyle: 'solid', margin: theme.spacing(0, -1), @@ -35,12 +37,13 @@ type CountingFilterProps = { query: string; // the query used as a source to count the number of element children: React.ReactNode; } & FilterProps; + function CountingFilter({ query, children, ...props }: CountingFilterProps) { const { data, loading, error } = useBugCountQuery({ variables: { query }, }); - var prefix; + let prefix; if (loading) prefix = '...'; else if (error || !data?.repository) prefix = '???'; // TODO: better prefixes & error handling @@ -57,14 +60,44 @@ type Props = { query: string; queryLocation: (query: string) => LocationDescriptor; }; + function FilterToolbar({ query, queryLocation }: Props) { const classes = useStyles(); const params: Query = parse(query); + const { data: identitiesData } = useListIdentitiesQuery(); + const { data: labelsData } = useListLabelsQuery(); + + let identities: any = []; + let labels: any = []; + + if ( + identitiesData?.repository && + identitiesData.repository.allIdentities && + identitiesData.repository.allIdentities.nodes + ) { + identities = identitiesData.repository.allIdentities.nodes.map((node) => [ + node.name, + node.name, + ]); + } + + if ( + labelsData?.repository && + labelsData.repository.validLabels && + labelsData.repository.validLabels.nodes + ) { + labels = labelsData.repository.validLabels.nodes.map((node) => [ + node.name, + node.name, + ]); + } 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 containsValue = (key: string, value: string): boolean => + hasKey(key) && params[key].indexOf(value) !== -1; const loc = pipe(stringify, queryLocation); const replaceParam = (key: string, value: string) => ( params: Query @@ -78,6 +111,20 @@ function FilterToolbar({ query, queryLocation }: Props) { ...params, [key]: params[key] && params[key].includes(value) ? [] : [value], }); + const toggleOrAddParam = (key: string, value: string) => ( + params: Query + ): Query => { + const values = params[key]; + return { + ...params, + [key]: + params[key] && params[key].includes(value) + ? values.filter((v) => v !== value) + : values + ? [...values, value] + : [value], + }; + }; const clearParam = (key: string) => (params: Query): Query => ({ ...params, [key]: [], @@ -116,6 +163,22 @@ function FilterToolbar({ query, queryLocation }: Props) { <Filter active={hasKey('label')}>Label</Filter> */} <FilterDropdown + dropdown={identities} + itemActive={(key) => hasValue('author', key)} + to={(key) => pipe(toggleOrAddParam('author', key), loc)(params)} + hasFilter + > + Author + </FilterDropdown> + <FilterDropdown + dropdown={labels} + itemActive={(key) => containsValue('label', key)} + to={(key) => pipe(toggleOrAddParam('label', key), loc)(params)} + hasFilter + > + Labels + </FilterDropdown> + <FilterDropdown dropdown={[ ['id', 'ID'], ['creation', 'Newest'], @@ -124,7 +187,7 @@ function FilterToolbar({ query, queryLocation }: Props) { ['edit-asc', 'Least recently updated'], ]} itemActive={(key) => hasValue('sort', key)} - to={(key) => pipe(replaceParam('sort', key), loc)(params)} + to={(key) => pipe(toggleParam('sort', key), loc)(params)} > Sort </FilterDropdown> diff --git a/webui/src/pages/list/ListIdentities.graphql b/webui/src/pages/list/ListIdentities.graphql new file mode 100644 index 00000000..73073ae8 --- /dev/null +++ b/webui/src/pages/list/ListIdentities.graphql @@ -0,0 +1,13 @@ +query ListIdentities { + repository { + allIdentities { + nodes { + id + humanId + name + email + displayName + } + } + } +} diff --git a/webui/src/pages/list/ListLabels.graphql b/webui/src/pages/list/ListLabels.graphql new file mode 100644 index 00000000..dcb44b67 --- /dev/null +++ b/webui/src/pages/list/ListLabels.graphql @@ -0,0 +1,9 @@ +query ListLabels { + repository { + validLabels { + nodes { + name + } + } + } +} diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx index 87c21e3c..2b46dca5 100644 --- a/webui/src/pages/list/ListQuery.tsx +++ b/webui/src/pages/list/ListQuery.tsx @@ -1,19 +1,23 @@ import { ApolloError } from '@apollo/client'; +import { pipe } from '@arrows/composition'; import React, { useState, useEffect, useRef } from 'react'; import { useLocation, useHistory, Link } from 'react-router-dom'; -import { Button } from '@material-ui/core'; +import { Button, FormControl, Menu, MenuItem } from '@material-ui/core'; 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 { makeStyles, Theme } from '@material-ui/core/styles'; +import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; 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 { useCurrentIdentityQuery } from '../../components/CurrentIdentity/CurrentIdentity.generated'; import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn'; +import { parse, Query, stringify } from './Filter'; import FilterToolbar from './FilterToolbar'; import List from './List'; import { useListBugsQuery } from './ListQuery.generated'; @@ -35,33 +39,27 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ }, header: { display: 'flex', - padding: theme.spacing(2), - '& > h1': { - ...theme.typography.h6, - margin: theme.spacing(0, 2), - }, - alignItems: 'center', - justifyContent: 'space-between', + padding: theme.spacing(1), }, filterissueLabel: { fontSize: '14px', fontWeight: 'bold', paddingRight: '12px', }, - filterissueContainer: { + form: { display: 'flex', - flexDirection: 'row', - alignItems: 'flex-start', - justifyContents: 'left', + flexGrow: 1, + marginRight: theme.spacing(1), }, search: { borderRadius: theme.shape.borderRadius, - borderColor: fade(theme.palette.primary.main, 0.2), + color: theme.palette.text.secondary, + borderColor: theme.palette.divider, borderStyle: 'solid', borderWidth: '1px', - backgroundColor: fade(theme.palette.primary.main, 0.05), + backgroundColor: theme.palette.primary.light, padding: theme.spacing(0, 1), - width: ({ searching }) => (searching ? '20rem' : '15rem'), + width: '100%', transition: theme.transitions.create([ 'width', 'borderColor', @@ -69,13 +67,11 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ ]), }, 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'], + borderBottomColor: theme.palette.divider, borderBottomWidth: '1px', borderBottomStyle: 'solid', display: 'flex', @@ -91,7 +87,8 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ ...theme.typography.h5, padding: theme.spacing(8), textAlign: 'center', - borderBottomColor: theme.palette.grey['300'], + color: theme.palette.text.hint, + borderBottomColor: theme.palette.divider, borderBottomWidth: '1px', borderBottomStyle: 'solid', '& > p': { @@ -99,21 +96,25 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({ }, }, errorBox: { - color: theme.palette.error.main, + color: theme.palette.error.dark, '& > pre': { fontSize: '1rem', textAlign: 'left', - backgroundColor: theme.palette.grey['900'], - color: theme.palette.common.white, + borderColor: theme.palette.divider, + borderWidth: '1px', + borderRadius: theme.shape.borderRadius, + borderStyle: 'solid', + color: theme.palette.text.primary, marginTop: theme.spacing(4), padding: theme.spacing(2, 3), }, }, greenButton: { - backgroundColor: '#2ea44fd9', - color: '#fff', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, '&:hover': { - backgroundColor: '#2ea44f', + backgroundColor: theme.palette.success.dark, + color: theme.palette.primary.contrastText, }, }, })); @@ -188,6 +189,8 @@ function ListQuery() { const query = params.has('q') ? params.get('q') || '' : 'status:open'; const [input, setInput] = useState(query); + const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false); + const filterButtonRef = useRef<HTMLButtonElement>(null); const classes = useStyles({ searching: !!input }); @@ -289,37 +292,87 @@ function ListQuery() { history.push(queryLocation(input)); }; + const { + loading: ciqLoading, + error: ciqError, + data: ciqData, + } = useCurrentIdentityQuery(); + if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) { + return null; + } + const user = ciqData.repository.userIdentity; + + const loc = pipe(stringify, queryLocation); + const qparams: Query = parse(query); + const replaceParam = (key: string, value: string) => ( + params: Query + ): Query => ({ + ...params, + [key]: [value], + }); + return ( <Paper className={classes.main}> <header className={classes.header}> - <div className="filterissueContainer"> - <form onSubmit={formSubmit}> - <label className={classes.filterissueLabel} htmlFor="issuefilter"> - Filter - </label> - <InputBase - id="issuefilter" - placeholder="Filter" - value={input} - onInput={(e: any) => setInput(e.target.value)} - classes={{ - root: classes.search, - focused: classes.searchFocused, + <form className={classes.form} onSubmit={formSubmit}> + <FormControl> + <Button + aria-haspopup="true" + ref={filterButtonRef} + onClick={(e) => setFilterMenuIsOpen(true)} + > + Filter <ArrowDropDownIcon /> + </Button> + <Menu + open={filterMenuIsOpen} + onClose={() => setFilterMenuIsOpen(false)} + getContentAnchorEl={null} + anchorEl={filterButtonRef.current} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', }} - /> - <button type="submit" hidden> - Search - </button> - </form> - </div> + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + <MenuItem + component={Link} + to={pipe( + replaceParam('author', user.displayName), + replaceParam('sort', 'creation'), + loc + )(qparams)} + onClick={() => setFilterMenuIsOpen(false)} + > + Your newest issues + </MenuItem> + </Menu> + </FormControl> + <InputBase + id="issuefilter" + 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> <IfLoggedIn> {() => ( <Button className={classes.greenButton} variant="contained" - href="/new" + component={Link} + to="/new" > - New issue + New bug </Button> )} </IfLoggedIn> diff --git a/webui/src/pages/new/NewBugPage.tsx b/webui/src/pages/new/NewBugPage.tsx index c9e268b6..4dc60e3c 100644 --- a/webui/src/pages/new/NewBugPage.tsx +++ b/webui/src/pages/new/NewBugPage.tsx @@ -1,10 +1,10 @@ import React, { FormEvent, useState } from 'react'; +import { useHistory } from 'react-router-dom'; -import { Button } from '@material-ui/core'; -import Paper from '@material-ui/core/Paper'; -import TextField from '@material-ui/core/TextField/TextField'; -import { fade, makeStyles, Theme } from '@material-ui/core/styles'; +import { Button, Paper } from '@material-ui/core'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import BugTitleInput from '../../components/BugTitleForm/BugTitleInput'; import CommentInput from '../../components/CommentInput/CommentInput'; import { useNewBugMutation } from './NewBug.generated'; @@ -21,19 +21,6 @@ const useStyles = makeStyles((theme: Theme) => ({ padding: theme.spacing(2), overflow: 'hidden', }, - titleInput: { - 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, 0), - transition: theme.transitions.create([ - 'width', - 'borderColor', - 'backgroundColor', - ]), - }, form: { display: 'flex', flexDirection: 'column', @@ -43,10 +30,11 @@ const useStyles = makeStyles((theme: Theme) => ({ justifyContent: 'flex-end', }, greenButton: { - backgroundColor: '#2ea44fd9', - color: '#fff', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, '&:hover': { - backgroundColor: '#2ea44f', + backgroundColor: theme.palette.success.dark, + color: theme.palette.primary.contrastText, }, }, })); @@ -59,7 +47,9 @@ function NewBugPage() { const [issueTitle, setIssueTitle] = useState(''); const [issueComment, setIssueComment] = useState(''); const classes = useStyles(); + let issueTitleInput: any; + let history = useHistory(); function submitNewIssue(e: FormEvent) { e.preventDefault(); @@ -71,12 +61,15 @@ function NewBugPage() { message: issueComment, }, }, + }).then(function (data) { + const id = data.data?.newBug.bug.humanId; + history.push('/bug/' + id); }); issueTitleInput.value = ''; } function isFormValid() { - return issueTitle.length > 0 && issueComment.length > 0 ? true : false; + return issueTitle.length > 0; } if (loading) return <div>Loading...</div>; @@ -85,12 +78,11 @@ function NewBugPage() { return ( <Paper className={classes.main}> <form className={classes.form} onSubmit={submitNewIssue}> - <TextField + <BugTitleInput inputRef={(node) => { issueTitleInput = node; }} label="Title" - className={classes.titleInput} variant="outlined" fullWidth margin="dense" @@ -107,7 +99,7 @@ function NewBugPage() { type="submit" disabled={isFormValid() ? false : true} > - Submit new issue + Submit new bug </Button> </div> </form> diff --git a/webui/src/pages/notfound/NotFoundPage.tsx b/webui/src/pages/notfound/NotFoundPage.tsx new file mode 100644 index 00000000..2c6f6854 --- /dev/null +++ b/webui/src/pages/notfound/NotFoundPage.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +import BackToListButton from '../../components/BackToListButton'; + +const useStyles = makeStyles((theme) => ({ + main: { + maxWidth: 1000, + margin: 'auto', + marginTop: theme.spacing(10), + }, + logo: { + height: '350px', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + }, + icon: { + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + fontSize: '80px', + }, + backLink: { + marginTop: theme.spacing(1), + textAlign: 'center', + }, + header: { + fontSize: '30px', + textAlign: 'center', + }, +})); + +function NotFoundPage() { + const classes = useStyles(); + return ( + <main className={classes.main}> + <h1 className={classes.header}>404 – Page not found</h1> + <img + src="/logo-alpha-flat-outline.svg" + className={classes.logo} + alt="git-bug Logo" + /> + <div className={classes.backLink}> + <BackToListButton /> + </div> + </main> + ); +} + +export default NotFoundPage; diff --git a/webui/src/theme.ts b/webui/src/theme.ts deleted file mode 100644 index d41cd731..00000000 --- a/webui/src/theme.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createMuiTheme } from '@material-ui/core/styles'; - -const theme = createMuiTheme({ - palette: { - primary: { - main: '#263238', - }, - }, -}); - -export default theme; diff --git a/webui/src/themes/DefaultDark.ts b/webui/src/themes/DefaultDark.ts new file mode 100644 index 00000000..65dd6329 --- /dev/null +++ b/webui/src/themes/DefaultDark.ts @@ -0,0 +1,26 @@ +import { createMuiTheme } from '@material-ui/core/styles'; + +const defaultDarkTheme = createMuiTheme({ + palette: { + type: 'dark', + primary: { + dark: '#263238', + main: '#2a393e', + light: '#525252', + }, + error: { + main: '#f44336', + dark: '#ff4949', + }, + info: { + main: '#2a393e', + contrastText: '#ffffffb3', + }, + success: { + main: '#2ea44fd9', + contrastText: '#fff', + }, + }, +}); + +export default defaultDarkTheme; diff --git a/webui/src/themes/DefaultLight.ts b/webui/src/themes/DefaultLight.ts new file mode 100644 index 00000000..9c57ebe5 --- /dev/null +++ b/webui/src/themes/DefaultLight.ts @@ -0,0 +1,26 @@ +import { createMuiTheme } from '@material-ui/core/styles'; + +const defaultLightTheme = createMuiTheme({ + palette: { + type: 'light', + primary: { + dark: '#263238', + main: '#5a6b73', + light: '#f5f5f5', + contrastText: '#fff', + }, + info: { + main: '#e2f1ff', + contrastText: '#555', + }, + success: { + main: '#2ea44fd9', + contrastText: '#fff', + }, + text: { + secondary: '#555', + }, + }, +}); + +export default defaultLightTheme; diff --git a/webui/src/themes/index.ts b/webui/src/themes/index.ts new file mode 100644 index 00000000..6c41c546 --- /dev/null +++ b/webui/src/themes/index.ts @@ -0,0 +1,4 @@ +import defaultDarkTheme from './DefaultDark'; +import defaultLightTheme from './DefaultLight'; + +export { defaultLightTheme, defaultDarkTheme }; |