Skip to content

Commit

Permalink
[218] Add a hovercard for user profiles
Browse files Browse the repository at this point in the history
Bug: #218
Signed-off-by: Stéphane Bégaudeau <[email protected]>
  • Loading branch information
sbegaudeau committed Jul 27, 2023
1 parent 4644fc1 commit a271ac0
Show file tree
Hide file tree
Showing 19 changed files with 325 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@

import jakarta.validation.constraints.NotNull;

import java.time.Instant;

/**
* The profile DTO for the GraphQL layer.
*
* @author sbegaudeau
*/
public record ProfileDTO(@NotNull String name, @NotNull String username, @NotNull String imageUrl) {
public record ProfileDTO(
@NotNull String name,
@NotNull String username,
@NotNull String imageUrl,
@NotNull Instant createdOn) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@ public Optional<Viewer> findViewerById(UUID id) {
@Override
@Transactional(readOnly = true)
public Optional<ProfileDTO> findProfileByUsername(String username) {
return this.accountRepository.findByUsername(username).map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
return this.accountRepository.findByUsername(username).map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public ActivityService(IAccountRepository accountRepository, IActivityEntryRepos

private Optional<ActivityEntryDTO> toDTO(ActivityEntry activityEntry) {
var optionalCreatedByProfile = this.accountRepository.findById(activityEntry.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalCreatedByProfile.map(createdBy -> new ActivityEntryDTO(
activityEntry.getId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ public Optional<BranchDTO> findByProjectIdAndName(UUID projectId, String name) {

private Optional<BranchDTO> toDTO(Branch branch) {
var optionalCreatedByProfile = this.accountRepository.findById(branch.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(branch.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var changeId = Optional.ofNullable(branch.getChange()).map(AggregateReference::getId).orElse(null);

return optionalCreatedByProfile.flatMap(createdBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ public ChangeProposalService(IAccountRepository accountRepository, IChangePropos

private Optional<ChangeProposalDTO> toDTO(ChangeProposal changeProposal) {
var optionalCreatedByProfile = this.accountRepository.findById(changeProposal.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(changeProposal.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalCreatedByProfile.flatMap(createdBy ->
optionalLastModifiedByProfile.map(lastModifiedBy ->
Expand Down Expand Up @@ -144,9 +144,9 @@ public IPayload createChangeProposal(CreateChangeProposalInput input) {

private Optional<ReviewDTO> toDTO(Review review) {
var optionalCreatedByProfile = this.accountRepository.findById(review.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(review.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalCreatedByProfile.flatMap(createdBy ->
optionalLastModifiedByProfile.map(lastModifiedBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ public ChangeService(IAccountRepository accountRepository, IChangeRepository cha

private Optional<ChangeDTO> toDTO(Change change) {
var optionalCreatedByProfile = this.accountRepository.findById(change.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(change.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalCreatedByProfile.flatMap(createdBy ->
optionalLastModifiedByProfile.map(lastModifiedBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ public NotificationService(IAccountRepository accountRepository, INotificationRe

private Optional<NotificationDTO> toDTO(Notification notification) {
var optionalOwnedByProfile = this.accountRepository.findById(notification.getOwnedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalCreatedByProfile = this.accountRepository.findById(notification.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(notification.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalOwnedByProfile.flatMap(ownedBy ->
optionalCreatedByProfile.flatMap(createdBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ public InvitationService(IAccountRepository accountRepository, IOrganizationRepo

private Optional<InvitationDTO> toDTO(Invitation invitation, UUID organizationId) {
var optionalCreatedByProfile = this.accountRepository.findById(invitation.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(invitation.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalMemberProfile = this.accountRepository.findById(invitation.getMemberId().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalMemberProfile.flatMap(member ->
optionalCreatedByProfile.flatMap(createdBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ public MembershipService(IAccountRepository accountRepository, IOrganizationRepo

private Optional<MembershipDTO> toDTO(Membership membership) {
var optionalCreatedByProfile = this.accountRepository.findById(membership.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(membership.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalMemberProfile = this.accountRepository.findById(membership.getMemberId().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalMemberProfile.flatMap(member ->
optionalCreatedByProfile.flatMap(createdBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ public OrganizationService(IAccountRepository accountRepository, IAvatarUrlServi

private Optional<OrganizationDTO> toDTO(Organization organization) {
var optionalCreatedByProfile = this.accountRepository.findById(organization.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(organization.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

var userId = UserIdProvider.get().getId();
return optionalCreatedByProfile.flatMap(createdBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ public ProjectService(IAccountRepository accountRepository, IProjectRepository p

private Optional<ProjectDTO> toDTO(Project project) {
var optionalCreatedByProfile = this.accountRepository.findById(project.getCreatedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));
var optionalLastModifiedByProfile = this.accountRepository.findById(project.getLastModifiedBy().getId())
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername())));
.map(account -> new ProfileDTO(account.getName(), account.getUsername(), this.avatarUrlService.imageUrl(account.getUsername()), account.getCreatedOn()));

return optionalCreatedByProfile.flatMap(createdBy ->
optionalLastModifiedByProfile.map(lastModifiedBy ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type Profile {
name: String!
username: String!
imageUrl: String!
createdOn: Instant!
activityEntries(page: Int!, rowsPerPage: Int!): ProfileActivityEntriesConnection!
}

Expand Down
23 changes: 2 additions & 21 deletions frontend/svalyn-studio-app/src/activity/ActivityTimelineItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,12 @@ import TimelineDot from '@mui/lab/TimelineDot';
import TimelineItem from '@mui/lab/TimelineItem';
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/material/styles';
import { Link as RouterLink } from 'react-router-dom';
import { formatTime } from '../utils/formatTime';
import { Person } from '../widgets/Person';
import { ActivityTimelineItemProps } from './ActivityTimelineItem.types';

export const ActivityTimelineItem = ({ date, kind, createdBy, title, description }: ActivityTimelineItemProps) => {
Expand Down Expand Up @@ -120,24 +118,7 @@ export const ActivityTimelineItem = ({ date, kind, createdBy, title, description
gap: (theme) => theme.spacing(0.5),
}}
>
<Tooltip title={createdBy.name}>
<Avatar
component={RouterLink}
to={`/profiles/${createdBy.username}`}
alt={createdBy.name}
src={createdBy.imageUrl}
sx={{ width: 24, height: 24 }}
/>
</Tooltip>
<Link
component={RouterLink}
to={`/profiles/${createdBy.username}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 'bold' }}
>
{createdBy.username}
</Link>
<Person profile={createdBy} variant="body1" />
<Typography>{description}</Typography>
</Box>
</TimelineContent>
Expand Down
118 changes: 118 additions & 0 deletions frontend/svalyn-studio-app/src/hovercards/ProfileHovercard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { gql, useQuery } from '@apollo/client';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import { forwardRef } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { formatTime } from '../utils/formatTime';
import { GetProfileData, GetProfileVariables, ProfileHovercardProps } from './ProfileHovercard.types';

const getProfileQuery = gql`
query getProfile($username: String!) {
viewer {
profile(username: $username) {
name
username
imageUrl
createdOn
}
}
}
`;

export const ProfileHovercard = forwardRef<HTMLDivElement, ProfileHovercardProps>(
({ open, anchorEl, onClose, onMouseLeave, username }: ProfileHovercardProps, ref) => {
const variables: GetProfileVariables = { username };
const { data } = useQuery<GetProfileData, GetProfileVariables>(getProfileQuery, { variables, skip: !open });

if (!data || !data.viewer.profile) {
return null;
}

const {
viewer: { profile },
} = data;
return (
<Popover
open={open}
onClose={onClose}
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
elevation={0}
sx={{
pointerEvents: 'none',
}}
slotProps={{
paper: {
ref,
onMouseLeave,
sx: {
pointerEvents: 'auto',
border: (theme) => `1px solid ${theme.palette.divider}`,
},
},
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: (theme) => theme.spacing(0.5),
padding: (theme) => theme.spacing(2),
width: (theme) => theme.spacing(50),
}}
>
<Avatar
component={RouterLink}
to={`/profiles/${profile.username}`}
alt={profile.name}
src={profile.imageUrl}
sx={{ width: 48, height: 48 }}
/>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', gap: (theme) => theme.spacing(1) }}>
<Link
component={RouterLink}
to={`/profiles/${profile.username}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 'bold' }}
>
{profile.username}
</Link>
<Typography variant="body1">{profile.name}</Typography>
</Box>
<Box
sx={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', gap: (theme) => theme.spacing(0.5) }}
>
<CalendarMonthIcon fontSize="small" />
<Typography variant="body2">Joined {formatTime(new Date(profile.createdOn))}</Typography>
</Box>
</Box>
</Popover>
);
}
);
Loading

0 comments on commit a271ac0

Please sign in to comment.