Merge pull request #605 from GlancingMind/upstream-12-allow-users-to-inspect-their-current-identity-details

Michael Muré created

WebUI: Add user profile

Change summary

api/graphql/schema/repository.graphql                    |   2 
webui/src/App.tsx                                        |   2 
webui/src/components/Author.tsx                          |  12 
webui/src/components/BugTitleForm/BugTitleForm.tsx       |   8 
webui/src/components/CurrentIdentity/CurrentIdentity.tsx |  31 --
webui/src/components/Header/Header.tsx                   |   2 
webui/src/components/Identity/CurrentIdentity.graphql    |   5 
webui/src/components/Identity/CurrentIdentity.tsx        | 114 +++++++
webui/src/components/Identity/IdentityFragment.graphql   |  10 
webui/src/components/Identity/UserIdentity.graphql       |   9 
webui/src/components/IfLoggedIn/IfLoggedIn.tsx           |   2 
webui/src/graphql/fragments.graphql                      |   2 
webui/src/pages/bug/LabelChange.tsx                      |   8 
webui/src/pages/bug/Message.tsx                          |   1 
webui/src/pages/bug/SetStatus.tsx                        |   8 
webui/src/pages/bug/SetTitle.tsx                         |   8 
webui/src/pages/identity/BugList.tsx                     |  73 ++++
webui/src/pages/identity/GetBugsByUser.graphql           |  12 
webui/src/pages/identity/GetUserStatistic.graphql        |  13 
webui/src/pages/identity/Identity.tsx                    | 147 ++++++++++
webui/src/pages/identity/IdentityQuery.tsx               |  24 +
webui/src/pages/identity/index.tsx                       |   1 
webui/src/pages/list/BugRow.tsx                          |   6 
webui/src/pages/list/ListQuery.tsx                       |   2 
webui/src/pages/new/NewBug.graphql                       |   4 
webui/src/pages/new/NewBugPage.tsx                       |   2 
26 files changed, 449 insertions(+), 59 deletions(-)

Detailed changes

webui/src/App.tsx 🔗

@@ -3,6 +3,7 @@ import { Route, Switch } from 'react-router';
 
 import Layout from './components/Header';
 import BugPage from './pages/bug';
+import IdentityPage from './pages/identity';
 import ListPage from './pages/list';
 import NewBugPage from './pages/new/NewBugPage';
 import NotFoundPage from './pages/notfound/NotFoundPage';
@@ -14,6 +15,7 @@ export default function App() {
         <Route path="/" exact component={ListPage} />
         <Route path="/new" exact component={NewBugPage} />
         <Route path="/bug/:id" exact component={BugPage} />
+        <Route path="/user/:id" exact component={IdentityPage} />
         <Route component={NotFoundPage} />
       </Switch>
     </Layout>

webui/src/components/Author.tsx 🔗

@@ -1,6 +1,8 @@
 import React from 'react';
+import { Link as RouterLink } from 'react-router-dom';
 
 import MAvatar from '@material-ui/core/Avatar';
+import Link from '@material-ui/core/Link';
 import Tooltip from '@material-ui/core/Tooltip/Tooltip';
 
 import { AuthoredFragment } from '../graphql/fragments.generated';
@@ -11,13 +13,11 @@ type Props = AuthoredFragment & {
 };
 
 const Author = ({ author, ...props }: Props) => {
-  if (!author.email) {
-    return <span {...props}>{author.displayName}</span>;
-  }
-
   return (
-    <Tooltip title={author.email}>
-      <span {...props}>{author.displayName}</span>
+    <Tooltip title={`Goto the ${author.displayName}'s profile.`}>
+      <Link {...props} component={RouterLink} to={`/user/${author.id}`}>
+        {author.displayName}
+      </Link>
     </Tooltip>
   );
 };

webui/src/components/BugTitleForm/BugTitleForm.tsx 🔗

@@ -52,6 +52,10 @@ const useStyles = makeStyles((theme) => ({
   saveButton: {
     marginRight: theme.spacing(1),
   },
+  author: {
+    fontWeight: 'bold',
+    color: theme.palette.text.secondary,
+  },
 }));
 
 interface Props {
@@ -86,7 +90,7 @@ function BugTitleForm({ bug }: Props) {
     setTitle({
       variables: {
         input: {
-          prefix: bug.humanId,
+          prefix: bug.id,
           title: issueTitleInput.value,
         },
       },
@@ -182,7 +186,7 @@ function BugTitleForm({ bug }: Props) {
       {bugTitleEdition ? editableBugTitle() : readonlyBugTitle()}
       <div className="classes.headerSubtitle">
         <Typography color={'textSecondary'}>
-          <Author author={bug.author} />
+          <Author author={bug.author} className={classes.author} />
           {' opened this bug '}
           <Date date={bug.createdAt} />
         </Typography>

webui/src/components/CurrentIdentity/CurrentIdentity.tsx 🔗

@@ -1,31 +0,0 @@
-import React from 'react';
-
-import Avatar from '@material-ui/core/Avatar';
-import { makeStyles } from '@material-ui/core/styles';
-
-import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
-
-const useStyles = makeStyles((theme) => ({
-  displayName: {
-    marginLeft: theme.spacing(2),
-  },
-}));
-
-const CurrentIdentity = () => {
-  const classes = useStyles();
-  const { loading, error, data } = useCurrentIdentityQuery();
-
-  if (error || loading || !data?.repository?.userIdentity) return null;
-
-  const user = data.repository.userIdentity;
-  return (
-    <>
-      <Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
-        {user.displayName.charAt(0).toUpperCase()}
-      </Avatar>
-      <div className={classes.displayName}>{user.displayName}</div>
-    </>
-  );
-};
-
-export default CurrentIdentity;

webui/src/components/Header/Header.tsx 🔗

@@ -8,7 +8,7 @@ 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 CurrentIdentity from '../Identity/CurrentIdentity';
 import { LightSwitch } from '../Themer';
 
 const useStyles = makeStyles((theme) => ({

webui/src/components/Identity/CurrentIdentity.tsx 🔗

@@ -0,0 +1,114 @@
+import React from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+
+import {
+  Button,
+  ClickAwayListener,
+  Grow,
+  Link,
+  MenuItem,
+  MenuList,
+  Paper,
+  Popper,
+} from '@material-ui/core';
+import Avatar from '@material-ui/core/Avatar';
+import { makeStyles } from '@material-ui/core/styles';
+import LockIcon from '@material-ui/icons/Lock';
+
+import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+
+const useStyles = makeStyles((theme) => ({
+  displayName: {
+    marginLeft: theme.spacing(2),
+  },
+  hidden: {
+    display: 'none',
+  },
+  profileLink: {
+    ...theme.typography.button,
+  },
+  popupButton: {
+    textTransform: 'none',
+    color: theme.palette.primary.contrastText,
+  },
+}));
+
+const CurrentIdentity = () => {
+  const classes = useStyles();
+  const { loading, error, data } = useCurrentIdentityQuery();
+
+  const [open, setOpen] = React.useState(false);
+  const anchorRef = React.useRef<HTMLButtonElement>(null);
+
+  if (error || loading || !data?.repository?.userIdentity) return null;
+
+  const user = data.repository.userIdentity;
+
+  const handleToggle = () => {
+    setOpen((prevOpen) => !prevOpen);
+  };
+
+  const handleClose = (event: any) => {
+    if (anchorRef.current && anchorRef.current.contains(event.target)) {
+      return;
+    }
+    setOpen(false);
+  };
+
+  return (
+    <>
+      <Button
+        ref={anchorRef}
+        aria-controls={open ? 'menu-list-grow' : undefined}
+        aria-haspopup="true"
+        onClick={handleToggle}
+        className={classes.popupButton}
+      >
+        <Avatar src={user.avatarUrl ? user.avatarUrl : undefined}>
+          {user.displayName.charAt(0).toUpperCase()}
+        </Avatar>
+        <div className={classes.displayName}>{user.displayName}</div>
+        <LockIcon
+          color="secondary"
+          className={user.isProtected ? '' : classes.hidden}
+        />
+      </Button>
+      <Popper
+        open={open}
+        anchorEl={anchorRef.current}
+        role={undefined}
+        transition
+        disablePortal
+      >
+        {({ TransitionProps, placement }) => (
+          <Grow
+            {...TransitionProps}
+            style={{
+              transformOrigin:
+                placement === 'bottom' ? 'center top' : 'center bottom',
+            }}
+          >
+            <Paper>
+              <ClickAwayListener onClickAway={handleClose}>
+                <MenuList autoFocusItem={open} id="menu-list-grow">
+                  <MenuItem>
+                    <Link
+                      color="inherit"
+                      className={classes.profileLink}
+                      component={RouterLink}
+                      to={`/user/${user.id}`}
+                    >
+                      Open profile
+                    </Link>
+                  </MenuItem>
+                </MenuList>
+              </ClickAwayListener>
+            </Paper>
+          </Grow>
+        )}
+      </Popper>
+    </>
+  );
+};
+
+export default CurrentIdentity;

webui/src/components/IfLoggedIn/IfLoggedIn.tsx 🔗

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import { useCurrentIdentityQuery } from '../CurrentIdentity/CurrentIdentity.generated';
+import { useCurrentIdentityQuery } from '../Identity/CurrentIdentity.generated';
 
 type Props = { children: () => React.ReactNode };
 const IfLoggedIn = ({ children }: Props) => {

webui/src/pages/bug/LabelChange.tsx 🔗

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { Typography } from '@material-ui/core';
 import { makeStyles } from '@material-ui/core/styles';
 
 import Author from 'src/components/Author';
@@ -10,11 +11,12 @@ import { LabelChangeFragment } from './LabelChangeFragment.generated';
 
 const useStyles = makeStyles((theme) => ({
   main: {
-    ...theme.typography.body2,
+    color: theme.palette.text.secondary,
     marginLeft: theme.spacing(1) + 40,
   },
   author: {
     fontWeight: 'bold',
+    color: theme.palette.text.secondary,
   },
   label: {
     maxWidth: '50ch',
@@ -31,7 +33,7 @@ function LabelChange({ op }: Props) {
   const { added, removed } = op;
   const classes = useStyles();
   return (
-    <div className={classes.main}>
+    <Typography className={classes.main}>
       <Author author={op.author} className={classes.author} />
       {added.length > 0 && <span> added the </span>}
       {added.map((label, index) => (
@@ -48,7 +50,7 @@ function LabelChange({ op }: Props) {
         {added.length + removed.length > 1 && 's'}{' '}
       </span>
       <Date date={op.date} />
-    </div>
+    </Typography>
   );
 }
 

webui/src/pages/bug/Message.tsx 🔗

@@ -21,6 +21,7 @@ import MessageHistoryDialog from './MessageHistoryDialog';
 const useStyles = makeStyles((theme) => ({
   author: {
     fontWeight: 'bold',
+    color: theme.palette.info.contrastText,
   },
   container: {
     display: 'flex',

webui/src/pages/bug/SetStatus.tsx 🔗

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { Typography } from '@material-ui/core';
 import { makeStyles } from '@material-ui/core/styles';
 
 import { Status } from '../../gqlTypes';
@@ -10,11 +11,12 @@ import { SetStatusFragment } from './SetStatusFragment.generated';
 
 const useStyles = makeStyles((theme) => ({
   main: {
-    ...theme.typography.body2,
+    color: theme.palette.text.secondary,
     marginLeft: theme.spacing(1) + 40,
   },
   author: {
     fontWeight: 'bold',
+    color: theme.palette.text.secondary,
   },
 }));
 
@@ -29,11 +31,11 @@ function SetStatus({ op }: Props) {
   ];
 
   return (
-    <div className={classes.main}>
+    <Typography className={classes.main}>
       <Author author={op.author} className={classes.author} />
       <span> {status} this </span>
       <Date date={op.date} />
-    </div>
+    </Typography>
   );
 }
 

webui/src/pages/bug/SetTitle.tsx 🔗

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { Typography } from '@material-ui/core';
 import { makeStyles } from '@material-ui/core/styles';
 
 import Author from 'src/components/Author';
@@ -9,11 +10,12 @@ import { SetTitleFragment } from './SetTitleFragment.generated';
 
 const useStyles = makeStyles((theme) => ({
   main: {
-    ...theme.typography.body2,
+    color: theme.palette.text.secondary,
     marginLeft: theme.spacing(1) + 40,
   },
   author: {
     fontWeight: 'bold',
+    color: theme.palette.text.secondary,
   },
   before: {
     fontWeight: 'bold',
@@ -31,14 +33,14 @@ type Props = {
 function SetTitle({ op }: Props) {
   const classes = useStyles();
   return (
-    <div className={classes.main}>
+    <Typography className={classes.main}>
       <Author author={op.author} className={classes.author} />
       <span> changed the title from </span>
       <span className={classes.before}>{op.was}</span>
       <span> to </span>
       <span className={classes.after}>{op.title}</span>&nbsp;
       <Date date={op.date} />
-    </div>
+    </Typography>
   );
 }
 

webui/src/pages/identity/BugList.tsx 🔗

@@ -0,0 +1,73 @@
+import React from 'react';
+
+import { Card, Divider, Link, Typography } from '@material-ui/core';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import { makeStyles } from '@material-ui/core/styles';
+
+import Date from '../../components/Date';
+
+import { useGetBugsByUserQuery } from './GetBugsByUser.generated';
+
+const useStyles = makeStyles((theme) => ({
+  main: {
+    ...theme.typography.body2,
+  },
+  bugLink: {
+    ...theme.typography.button,
+  },
+  cards: {
+    backgroundColor: theme.palette.background.default,
+    color: theme.palette.info.contrastText,
+    padding: theme.spacing(1),
+    margin: theme.spacing(1),
+  },
+}));
+
+type Props = {
+  id: string;
+};
+
+function BugList({ id }: Props) {
+  const classes = useStyles();
+  const { loading, error, data } = useGetBugsByUserQuery({
+    variables: {
+      query: 'author:' + id + ' sort:creation',
+    },
+  });
+
+  if (loading) return <CircularProgress />;
+  if (error) return <p>Error: {error}</p>;
+  const bugs = data?.repository?.allBugs.nodes;
+
+  return (
+    <div className={classes.main}>
+      {bugs?.map((bug, index) => {
+        return (
+          <Card className={classes.cards} key={index}>
+            <Typography variant="overline" component="h2">
+              <Link
+                className={classes.bugLink}
+                href={'/bug/' + bug.id}
+                color={'inherit'}
+              >
+                {bug.title}
+              </Link>
+            </Typography>
+            <Divider />
+            <Typography variant="subtitle2">
+              Created&nbsp;
+              <Date date={bug.createdAt} />
+            </Typography>
+            <Typography variant="subtitle2">
+              Last edited&nbsp;
+              <Date date={bug.createdAt} />
+            </Typography>
+          </Card>
+        );
+      })}
+      {bugs?.length === 0 && <p>No authored bugs by this user found.</p>}
+    </div>
+  );
+}
+
+export default BugList;

webui/src/pages/identity/GetUserStatistic.graphql 🔗

@@ -0,0 +1,13 @@
+query GetUserStatistic($authorQuery: String!, $participantQuery: String!, $actionQuery: String!) {
+  repository {
+    authored: allBugs(query: $authorQuery) {
+      totalCount
+    },
+    participated: allBugs(query: $participantQuery) {
+      totalCount
+    }
+    actions: allBugs(query: $actionQuery) {
+      totalCount
+    }
+  }
+}

webui/src/pages/identity/Identity.tsx 🔗

@@ -0,0 +1,147 @@
+import React from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+
+import { Link, Paper, Typography } from '@material-ui/core';
+import Avatar from '@material-ui/core/Avatar';
+import CircularProgress from '@material-ui/core/CircularProgress';
+import Grid from '@material-ui/core/Grid';
+import { makeStyles } from '@material-ui/core/styles';
+import InfoIcon from '@material-ui/icons/Info';
+import MailOutlineIcon from '@material-ui/icons/MailOutline';
+
+import { IdentityFragment } from '../../components/Identity/IdentityFragment.generated';
+
+import { useGetUserStatisticQuery } from './GetUserStatistic.generated';
+
+const useStyles = makeStyles((theme) => ({
+  main: {
+    maxWidth: 1000,
+    margin: 'auto',
+    marginTop: theme.spacing(3),
+  },
+  content: {
+    padding: theme.spacing(0.5, 2, 2, 2),
+    wordWrap: 'break-word',
+  },
+  large: {
+    minWidth: 200,
+    minHeight: 200,
+    margin: 'auto',
+    maxWidth: '100%',
+    maxHeight: '100%',
+  },
+  heading: {
+    marginTop: theme.spacing(3),
+  },
+  header: {
+    ...theme.typography.h4,
+    wordBreak: 'break-word',
+  },
+  infoIcon: {
+    verticalAlign: 'bottom',
+  },
+}));
+
+type Props = {
+  identity: IdentityFragment;
+};
+const Identity = ({ identity }: Props) => {
+  const classes = useStyles();
+  const user = identity;
+
+  const { loading, error, data } = useGetUserStatisticQuery({
+    variables: {
+      authorQuery: 'author:' + user?.id,
+      participantQuery: 'participant:' + user?.id,
+      actionQuery: 'actor:' + user?.id,
+    },
+  });
+
+  if (loading) return <CircularProgress />;
+  if (error) return <p>Error: {error}</p>;
+  const statistic = data?.repository;
+  const authoredCount = statistic?.authored?.totalCount;
+  const participatedCount = statistic?.participated?.totalCount;
+  const actionCount = statistic?.actions?.totalCount;
+
+  return (
+    <main className={classes.main}>
+      <Paper elevation={3} className={classes.content}>
+        <Grid spacing={2} container direction="row">
+          <Grid xs={12} sm={4} className={classes.heading} item>
+            <Avatar
+              src={user?.avatarUrl ? user.avatarUrl : undefined}
+              className={classes.large}
+            >
+              {user?.displayName.charAt(0).toUpperCase()}
+            </Avatar>
+          </Grid>
+          <Grid xs={12} sm={4} item>
+            <section>
+              <h1 className={classes.header}>{user?.name}</h1>
+              <Typography variant="subtitle1">
+                Name: {user?.displayName ? user?.displayName : '---'}
+              </Typography>
+              <Typography variant="subtitle1">
+                Id (truncated): {user?.humanId ? user?.humanId : '---'}
+                <InfoIcon
+                  titleAccess={user?.id ? user?.id : '---'}
+                  className={classes.infoIcon}
+                />
+              </Typography>
+              {user?.email && (
+                <Typography
+                  variant="subtitle1"
+                  style={{
+                    display: 'flex',
+                    alignItems: 'center',
+                    flexWrap: 'wrap',
+                  }}
+                >
+                  <MailOutlineIcon />
+                  <Link href={'mailto:' + user?.email} color={'inherit'}>
+                    {user?.email}
+                  </Link>
+                </Typography>
+              )}
+            </section>
+          </Grid>
+          <Grid xs={12} sm={4} item>
+            <section>
+              <h1 className={classes.header}>Statistics</h1>
+              <Link
+                component={RouterLink}
+                to={`/?q=author%3A${user?.id}+sort%3Acreation`}
+                color={'inherit'}
+              >
+                <Typography variant="subtitle1">
+                  Created {authoredCount} bugs.
+                </Typography>
+              </Link>
+              <Link
+                component={RouterLink}
+                to={`/?q=participant%3A${user?.id}+sort%3Acreation`}
+                color={'inherit'}
+              >
+                <Typography variant="subtitle1">
+                  Participated to {participatedCount} bugs.
+                </Typography>
+              </Link>
+              <Link
+                component={RouterLink}
+                to={`/?q=actor%3A${user?.id}+sort%3Acreation`}
+                color={'inherit'}
+              >
+                <Typography variant="subtitle1">
+                  Interacted with {actionCount} bugs.
+                </Typography>
+              </Link>
+            </section>
+          </Grid>
+        </Grid>
+      </Paper>
+    </main>
+  );
+};
+
+export default Identity;

webui/src/pages/identity/IdentityQuery.tsx 🔗

@@ -0,0 +1,24 @@
+import React from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+
+import CircularProgress from '@material-ui/core/CircularProgress';
+
+import { useGetUserByIdQuery } from '../../components/Identity/UserIdentity.generated';
+
+import Identity from './Identity';
+
+type Props = RouteComponentProps<{
+  id: string;
+}>;
+
+const UserQuery: React.FC<Props> = ({ match }: Props) => {
+  const { loading, error, data } = useGetUserByIdQuery({
+    variables: { userId: match.params.id },
+  });
+  if (loading) return <CircularProgress />;
+  if (error) return <p>Error: {error}</p>;
+  if (!data?.repository?.identity) return <p>404.</p>;
+  return <Identity identity={data.repository.identity} />;
+};
+
+export default UserQuery;

webui/src/pages/list/BugRow.tsx 🔗

@@ -9,6 +9,7 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import CommentOutlinedIcon from '@material-ui/icons/CommentOutlined';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
 
+import Author from 'src/components/Author';
 import Date from 'src/components/Date';
 import Label from 'src/components/Label';
 import { Status } from 'src/gqlTypes';
@@ -105,7 +106,7 @@ function BugRow({ bug }: Props) {
       <TableCell className={classes.cell}>
         <BugStatus status={bug.status} className={classes.status} />
         <div className={classes.expand}>
-          <Link to={'bug/' + bug.humanId}>
+          <Link to={'bug/' + bug.id}>
             <div className={classes.bugTitleWrapper}>
               <span className={classes.title}>{bug.title}</span>
               {bug.labels.length > 0 &&
@@ -117,7 +118,8 @@ function BugRow({ bug }: Props) {
           <div className={classes.details}>
             {bug.humanId} opened&nbsp;
             <Date date={bug.createdAt} />
-            &nbsp;by {bug.author.displayName}
+            &nbsp;by&nbsp;
+            <Author className={classes.details} author={bug.author} />
           </div>
         </div>
         <span className={classes.commentCountCell}>

webui/src/pages/list/ListQuery.tsx 🔗

@@ -14,7 +14,7 @@ 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 { useCurrentIdentityQuery } from '../../components/Identity/CurrentIdentity.generated';
 import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 
 import { parse, Query, stringify } from './Filter';

webui/src/pages/new/NewBugPage.tsx 🔗

@@ -62,7 +62,7 @@ function NewBugPage() {
         },
       },
     }).then(function (data) {
-      const id = data.data?.newBug.bug.humanId;
+      const id = data.data?.newBug.bug.id;
       history.push('/bug/' + id);
     });
     issueTitleInput.value = '';