mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: Project owners UI (#6949)
--------- Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
parent
3978c690e0
commit
b6865a5a9d
BIN
frontend/public/logo-unleash.png
Normal file
BIN
frontend/public/logo-unleash.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
5
frontend/src/assets/icons/projectIconSmall.svg
Normal file
5
frontend/src/assets/icons/projectIconSmall.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="37" height="35" viewBox="0 0 37 35" fill="none">
|
||||
<rect x="8.9864" y="9.93245" width="3.78378" height="13.2432" rx="1.41892" fill="#6C65E5" stroke="#6C65E5" stroke-width="0.945947" />
|
||||
<rect x="16.5542" y="13.7161" width="3.78378" height="9.45946" rx="1.41892" fill="#6C65E5" stroke="#6C65E5" stroke-width="0.945947" />
|
||||
<rect x="24.1217" y="18.4456" width="3.78378" height="4.72973" rx="1.41892" fill="#6C65E5" stroke="#6C65E5" stroke-width="0.945947" />
|
||||
</svg>
|
After Width: | Height: | Size: 519 B |
@ -25,6 +25,9 @@ interface IGroupCardAvatarsProps {
|
||||
users: IGroupUser[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Remove after with `projectsListNewCards` flag
|
||||
*/
|
||||
export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
|
||||
const shownUsers = useMemo(
|
||||
() =>
|
||||
|
@ -15,7 +15,7 @@ const StyledName = styled('div')(({ theme }) => ({
|
||||
}));
|
||||
|
||||
interface IGroupPopoverProps {
|
||||
user: IGroupUser | undefined;
|
||||
user: Partial<IGroupUser & { description?: string }> | undefined;
|
||||
|
||||
open: boolean;
|
||||
anchorEl: HTMLElement | null;
|
||||
@ -44,7 +44,7 @@ export const GroupPopover = ({
|
||||
}}
|
||||
>
|
||||
<StyledName>{user?.name || user?.username}</StyledName>
|
||||
<div>{user?.email}</div>
|
||||
<div>{user?.description || user?.email}</div>
|
||||
</StyledPopover>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,128 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import type { IGroupUser } from 'interfaces/group';
|
||||
import type React from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { GroupPopover } from './GroupPopover/GroupPopover';
|
||||
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||
import { objectId } from 'utils/objectId';
|
||||
|
||||
const StyledContainer = styled('div')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}));
|
||||
|
||||
const StyledAvatars = styled('div')(({ theme }) => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledAvatar = styled(UserAvatar)(({ theme }) => ({
|
||||
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
|
||||
marginLeft: theme.spacing(-1),
|
||||
'&:hover': {
|
||||
outlineColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledUsername = styled('div')(({ theme }) => ({
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
color: theme.palette.text.primary,
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledHeader = styled('h3')(({ theme }) => ({
|
||||
margin: theme.spacing(0, 0, 1),
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
}));
|
||||
|
||||
interface IGroupCardAvatarsProps {
|
||||
users: {
|
||||
name: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
}[];
|
||||
header?: ReactNode;
|
||||
}
|
||||
|
||||
export const GroupCardAvatars = ({
|
||||
users = [],
|
||||
header = null,
|
||||
}: IGroupCardAvatarsProps) => {
|
||||
const shownUsers = useMemo(
|
||||
() =>
|
||||
users
|
||||
.sort((a, b) => {
|
||||
if (
|
||||
Object.hasOwn(a, 'joinedAt') &&
|
||||
Object.hasOwn(b, 'joinedAt')
|
||||
) {
|
||||
return (
|
||||
(b as IGroupUser)?.joinedAt!.getTime() -
|
||||
(a as IGroupUser)?.joinedAt!.getTime()
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 9),
|
||||
[users],
|
||||
);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [popupUser, setPopupUser] = useState<{
|
||||
name: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
}>();
|
||||
|
||||
const onPopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const onPopoverClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const avatarOpen = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ConditionallyRender
|
||||
condition={typeof header === 'string'}
|
||||
show={<StyledHeader>{header}</StyledHeader>}
|
||||
elseShow={header}
|
||||
/>
|
||||
<StyledAvatars>
|
||||
{shownUsers.map((user) => (
|
||||
<StyledAvatar
|
||||
key={objectId(user)}
|
||||
user={{ ...user, id: objectId(user) }}
|
||||
onMouseEnter={(event) => {
|
||||
onPopoverOpen(event);
|
||||
setPopupUser(user);
|
||||
}}
|
||||
onMouseLeave={onPopoverClose}
|
||||
/>
|
||||
))}
|
||||
<ConditionallyRender
|
||||
condition={users.length > 9}
|
||||
show={
|
||||
<StyledAvatar>
|
||||
+{users.length - shownUsers.length}
|
||||
</StyledAvatar>
|
||||
}
|
||||
/>
|
||||
<GroupPopover
|
||||
open={avatarOpen}
|
||||
user={popupUser}
|
||||
anchorEl={anchorEl}
|
||||
onPopoverClose={onPopoverClose}
|
||||
/>
|
||||
</StyledAvatars>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
@ -59,21 +59,30 @@ const StyledBadge = styled('span')<IBadgeProps>(
|
||||
}),
|
||||
);
|
||||
|
||||
const StyledBadgeIcon = styled('span')<IBadgeIconProps>(
|
||||
({ theme, color = 'neutral', iconRight = false }) => ({
|
||||
display: 'flex',
|
||||
color:
|
||||
color === 'disabled'
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette[color].main,
|
||||
margin: iconRight
|
||||
? theme.spacing(0, 0, 0, 0.5)
|
||||
: theme.spacing(0, 0.5, 0, 0),
|
||||
}),
|
||||
);
|
||||
const StyledBadgeIcon = styled('span')<
|
||||
IBadgeIconProps & { hasChildren?: boolean }
|
||||
>(({ theme, color = 'neutral', iconRight = false, hasChildren }) => ({
|
||||
display: 'flex',
|
||||
color:
|
||||
color === 'disabled'
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette[color].main,
|
||||
margin: iconRight
|
||||
? theme.spacing(0, 0, 0, hasChildren ? 0.5 : 0)
|
||||
: theme.spacing(0, hasChildren ? 0.5 : 0, 0, 0),
|
||||
}));
|
||||
|
||||
const BadgeIcon = (color: Color, icon: ReactElement, iconRight = false) => (
|
||||
<StyledBadgeIcon color={color} iconRight={iconRight}>
|
||||
const BadgeIcon = (
|
||||
color: Color,
|
||||
icon: ReactElement,
|
||||
iconRight = false,
|
||||
hasChildren = false,
|
||||
) => (
|
||||
<StyledBadgeIcon
|
||||
color={color}
|
||||
iconRight={iconRight}
|
||||
hasChildren={hasChildren}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon?.props.sx)}
|
||||
show={icon}
|
||||
@ -112,12 +121,12 @@ export const Badge: FC<IBadgeProps> = forwardRef(
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon) && !iconRight}
|
||||
show={BadgeIcon(color, icon!)}
|
||||
show={BadgeIcon(color, icon!, false, Boolean(children))}
|
||||
/>
|
||||
{children}
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon) && Boolean(iconRight)}
|
||||
show={BadgeIcon(color, icon!, true)}
|
||||
show={BadgeIcon(color, icon!, true, Boolean(children))}
|
||||
/>
|
||||
</StyledBadge>
|
||||
),
|
||||
|
@ -20,7 +20,9 @@ const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||
}));
|
||||
|
||||
interface IUserAvatarProps extends AvatarProps {
|
||||
user?: IUser;
|
||||
user?: Partial<
|
||||
Pick<IUser, 'id' | 'name' | 'email' | 'username' | 'imageUrl'>
|
||||
>;
|
||||
src?: string;
|
||||
title?: string;
|
||||
onMouseEnter?: (event: any) => void;
|
||||
|
@ -57,7 +57,7 @@ const StyledAccordionDetails = styled(AccordionDetails, {
|
||||
background: theme.palette.envAccordion.expanded,
|
||||
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
|
||||
borderBottomRightRadius: theme.shape.borderRadiusLarge,
|
||||
boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // replace this with variable
|
||||
boxShadow: theme.boxShadows.accordionFooter,
|
||||
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: theme.spacing(2, 1),
|
||||
|
@ -3,6 +3,7 @@ import { Card, Box } from '@mui/material';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Edit from '@mui/icons-material/Edit';
|
||||
import { flexRow } from 'themes/themeStyles';
|
||||
import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg';
|
||||
|
||||
export const StyledProjectCard = styled(Card)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -31,9 +32,9 @@ export const StyledDivHeader = styled('div')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const StyledH2Title = styled('h2')(({ theme }) => ({
|
||||
fontWeight: 'normal',
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
export const StyledCardTitle = styled('h3')(({ theme }) => ({
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
lineClamp: '2',
|
||||
WebkitLineClamp: 2,
|
||||
lineHeight: '1.2',
|
||||
@ -65,13 +66,22 @@ export const StyledDivInfo = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
}));
|
||||
|
||||
export const StyledDivInfoContainer = styled('div')(() => ({
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(0, 1),
|
||||
}));
|
||||
|
||||
export const StyledParagraphInfo = styled('p')(({ theme }) => ({
|
||||
color: theme.palette.primary.dark,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
}));
|
||||
|
||||
export const StyledProjectIcon = styled(ProjectIcon)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
}));
|
||||
|
||||
export const StyledIconBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(0.5, 0.5, 0.5, 0),
|
||||
marginRight: theme.spacing(2),
|
||||
}));
|
||||
|
@ -1,28 +1,20 @@
|
||||
import type React from 'react';
|
||||
import { Menu, MenuItem } from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { type SyntheticEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getProjectEditPath } from 'utils/routePathHelpers';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue';
|
||||
import {
|
||||
StyledProjectCard,
|
||||
StyledDivHeader,
|
||||
StyledBox,
|
||||
StyledH2Title,
|
||||
StyledEditIcon,
|
||||
StyledCardTitle,
|
||||
StyledDivInfo,
|
||||
StyledDivInfoContainer,
|
||||
StyledParagraphInfo,
|
||||
StyledProjectCardBody,
|
||||
StyledIconBox,
|
||||
StyledProjectIcon,
|
||||
} from './NewProjectCard.styles';
|
||||
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
|
||||
import { ProjectCardIcon } from './ProjectCardIcon/ProjectCardIcon';
|
||||
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
|
||||
import { ProjectOwners } from './ProjectOwners/ProjectOwners';
|
||||
import type { ProjectSchemaOwners } from 'openapi';
|
||||
|
||||
interface IProjectCardProps {
|
||||
name: string;
|
||||
@ -33,6 +25,7 @@ interface IProjectCardProps {
|
||||
onHover: () => void;
|
||||
isFavorite?: boolean;
|
||||
mode: string;
|
||||
owners?: ProjectSchemaOwners;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
@ -44,100 +37,49 @@ export const ProjectCard = ({
|
||||
id,
|
||||
mode,
|
||||
isFavorite = false,
|
||||
}: IProjectCardProps) => {
|
||||
const { isOss } = useUiConfig();
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
const [showDelDialog, setShowDelDialog] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledProjectCard onMouseEnter={onHover}>
|
||||
<StyledProjectCardBody>
|
||||
<StyledDivHeader data-loading>
|
||||
<ProjectCardIcon mode={mode} />
|
||||
<StyledBox>
|
||||
<StyledH2Title>{name}</StyledH2Title>
|
||||
</StyledBox>
|
||||
<PermissionIconButton
|
||||
style={{ transform: 'translateX(7px)' }}
|
||||
permission={UPDATE_PROJECT}
|
||||
hidden={isOss()}
|
||||
projectId={id}
|
||||
data-loading
|
||||
onClick={handleClick}
|
||||
tooltipProps={{
|
||||
title: 'Options',
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</PermissionIconButton>
|
||||
|
||||
<Menu
|
||||
id='project-card-menu'
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
style={{ top: 0, left: -100 }}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClose={(event: SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(getProjectEditPath(id));
|
||||
}}
|
||||
>
|
||||
<StyledEditIcon />
|
||||
Edit project
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StyledDivHeader>
|
||||
<StyledDivInfo>
|
||||
<StyledDivInfoContainer>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{featureCount}
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>toggles</p>
|
||||
</StyledDivInfoContainer>
|
||||
<StyledDivInfoContainer>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{health}%
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>health</p>
|
||||
</StyledDivInfoContainer>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={id !== DEFAULT_PROJECT_ID}
|
||||
show={
|
||||
<StyledDivInfoContainer>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{memberCount}
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>members</p>
|
||||
</StyledDivInfoContainer>
|
||||
}
|
||||
/>
|
||||
</StyledDivInfo>
|
||||
</StyledProjectCardBody>
|
||||
<ProjectCardFooter id={id} isFavorite={isFavorite} />
|
||||
<DeleteProjectDialogue
|
||||
project={id}
|
||||
open={showDelDialog}
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
setAnchorEl(null);
|
||||
setShowDelDialog(false);
|
||||
}}
|
||||
/>
|
||||
</StyledProjectCard>
|
||||
);
|
||||
};
|
||||
owners,
|
||||
}: IProjectCardProps) => (
|
||||
<StyledProjectCard onMouseEnter={onHover}>
|
||||
<StyledProjectCardBody>
|
||||
<StyledDivHeader>
|
||||
<StyledIconBox>
|
||||
<StyledProjectIcon />
|
||||
</StyledIconBox>
|
||||
<StyledBox data-loading>
|
||||
<StyledCardTitle>{name}</StyledCardTitle>
|
||||
</StyledBox>
|
||||
<ProjectModeBadge mode={mode} />
|
||||
</StyledDivHeader>
|
||||
<StyledDivInfo>
|
||||
<div>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{featureCount}
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>{featureCount === 1 ? 'flag' : 'flags'}</p>
|
||||
</div>
|
||||
<ConditionallyRender
|
||||
condition={id !== DEFAULT_PROJECT_ID}
|
||||
show={
|
||||
<div>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{memberCount}
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>
|
||||
{memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<StyledParagraphInfo data-loading>
|
||||
{health}%
|
||||
</StyledParagraphInfo>
|
||||
<p data-loading>healthy</p>
|
||||
</div>
|
||||
</StyledDivInfo>
|
||||
</StyledProjectCardBody>
|
||||
<ProjectCardFooter id={id} isFavorite={isFavorite}>
|
||||
<ProjectOwners owners={owners} />
|
||||
</ProjectCardFooter>
|
||||
</StyledProjectCard>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { VFC } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
|
||||
import useToast from 'hooks/useToast';
|
||||
@ -14,10 +14,9 @@ const StyledFooter = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1, 2),
|
||||
borderTop: `1px solid ${theme.palette.grey[300]}`,
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // FIXME: replace with variable
|
||||
padding: theme.spacing(1.5, 3),
|
||||
background: theme.palette.envAccordion.expanded,
|
||||
boxShadow: theme.boxShadows.accordionFooter,
|
||||
}));
|
||||
|
||||
const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
|
||||
@ -25,7 +24,8 @@ const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
|
||||
marginLeft: 'auto',
|
||||
}));
|
||||
|
||||
export const ProjectCardFooter: VFC<IProjectCardFooterProps> = ({
|
||||
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
|
||||
children,
|
||||
id,
|
||||
isFavorite = false,
|
||||
}) => {
|
||||
@ -48,6 +48,7 @@ export const ProjectCardFooter: VFC<IProjectCardFooterProps> = ({
|
||||
};
|
||||
return (
|
||||
<StyledFooter>
|
||||
{children}
|
||||
<StyledFavoriteIconButton
|
||||
onClick={onFavorite}
|
||||
isFavorite={isFavorite}
|
||||
|
@ -1,68 +0,0 @@
|
||||
import type { VFC } from 'react';
|
||||
import { styled } from '@mui/material';
|
||||
import { Box } from '@mui/material';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
|
||||
interface IProjectCardIconProps {
|
||||
mode: 'private' | 'protected' | 'public' | string;
|
||||
}
|
||||
|
||||
const StyledVisibilityIcon = styled(VisibilityOffIcon)(({ theme }) => ({
|
||||
color: theme.palette.action.disabled,
|
||||
}));
|
||||
|
||||
const StyledLockIcon = styled(LockIcon)(({ theme }) => ({
|
||||
color: theme.palette.action.disabled,
|
||||
}));
|
||||
|
||||
const StyledProjectIcon = styled(BarChartIcon)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
}));
|
||||
|
||||
export const StyledIconBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
borderWidth: '1px',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.palette.neutral.border,
|
||||
padding: theme.spacing(0.5),
|
||||
marginRight: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const ProjectCardIcon: VFC<IProjectCardIconProps> = ({ mode }) => {
|
||||
if (mode === 'private') {
|
||||
return (
|
||||
<StyledIconBox data-loading>
|
||||
<HtmlTooltip
|
||||
title="This project's collaboration mode is set to private. The project and associated feature flags can only be seen by members of the project."
|
||||
arrow
|
||||
>
|
||||
<StyledVisibilityIcon />
|
||||
</HtmlTooltip>
|
||||
</StyledIconBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'protected') {
|
||||
return (
|
||||
<StyledIconBox data-loading>
|
||||
<HtmlTooltip
|
||||
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."
|
||||
arrow
|
||||
>
|
||||
<StyledLockIcon />
|
||||
</HtmlTooltip>
|
||||
</StyledIconBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledIconBox data-loading>
|
||||
<StyledProjectIcon />
|
||||
</StyledIconBox>
|
||||
);
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import type { VFC } from 'react';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
|
||||
interface IProjectModeBadgeProps {
|
||||
mode: 'private' | 'protected' | 'public' | string;
|
||||
}
|
||||
|
||||
export const ProjectModeBadge: VFC<IProjectModeBadgeProps> = ({ mode }) => {
|
||||
if (mode === 'private') {
|
||||
return (
|
||||
<HtmlTooltip
|
||||
title="This project's collaboration mode is set to private. The project and associated feature flags can only be seen by members of the project."
|
||||
arrow
|
||||
>
|
||||
<Badge color='warning' icon={<VisibilityOffIcon />} />
|
||||
</HtmlTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'protected') {
|
||||
return (
|
||||
<HtmlTooltip
|
||||
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."
|
||||
arrow
|
||||
>
|
||||
<Badge color='warning' icon={<LockIcon />} />
|
||||
</HtmlTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -0,0 +1,75 @@
|
||||
import type { FC } from 'react';
|
||||
import { styled } from '@mui/material';
|
||||
import { GroupCardAvatars } from 'component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars';
|
||||
import type { ProjectSchema, ProjectSchemaOwners } from 'openapi';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
interface IProjectOwnersProps {
|
||||
owners?: ProjectSchema['owners'];
|
||||
}
|
||||
|
||||
const useOwnersMap = () => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
return (
|
||||
owner: ProjectSchemaOwners[0],
|
||||
): {
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
} => {
|
||||
if (owner.ownerType === 'user') {
|
||||
return {
|
||||
name: owner.name,
|
||||
imageUrl: owner.imageUrl || undefined,
|
||||
email: owner.email || undefined,
|
||||
};
|
||||
}
|
||||
if (owner.ownerType === 'group') {
|
||||
return {
|
||||
name: owner.name || '',
|
||||
description: 'group',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: '',
|
||||
description: 'System',
|
||||
imageUrl: `${uiConfig.unleashUrl}/logo-unleash.png`,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
}));
|
||||
|
||||
const StyledUserName = styled('p')(({ theme }) => ({
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
margin: theme.spacing(0, 0, 0.5, 0),
|
||||
}));
|
||||
|
||||
export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
|
||||
const ownersMap = useOwnersMap();
|
||||
const users = owners.map(ownersMap);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<GroupCardAvatars
|
||||
header={owners.length === 1 ? 'Owner' : 'Owners'}
|
||||
users={users}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={owners.length === 1}
|
||||
show={
|
||||
<StyledUserName>
|
||||
{users[0]?.name || users[0]?.description}
|
||||
</StyledUserName>
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
@ -46,7 +46,7 @@ const StyledAccordionDetails = styled(AccordionDetails, {
|
||||
background: theme.palette.envAccordion.expanded,
|
||||
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
|
||||
borderBottomRightRadius: theme.shape.borderRadiusLarge,
|
||||
boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // replace this with variable
|
||||
boxShadow: theme.boxShadows.accordionFooter,
|
||||
|
||||
[theme.breakpoints.down('md')]: {
|
||||
padding: theme.spacing(2, 1),
|
||||
|
@ -8,16 +8,6 @@ import { TablePlaceholder } from 'component/common/Table';
|
||||
import { styled, Typography } from '@mui/material';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
const StyledProjectGroupContainer = styled('article')(({ theme }) => ({
|
||||
h3: {
|
||||
marginBlockEnd: theme.spacing(2),
|
||||
},
|
||||
|
||||
'&+&': {
|
||||
marginBlockStart: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @deprecated Remove after with `projectsListNewCards` flag
|
||||
*/
|
||||
@ -31,7 +21,7 @@ const StyledDivContainer = styled('div')(({ theme }) => ({
|
||||
|
||||
const StyledGridContainer = styled('div')(({ theme }) => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
@ -59,10 +49,18 @@ export const ProjectGroup: React.FC<{
|
||||
: [StyledDivContainer, LegacyProjectCard];
|
||||
|
||||
return (
|
||||
<StyledProjectGroupContainer>
|
||||
<article>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(sectionTitle)}
|
||||
show={<Typography component='h3'>{sectionTitle}</Typography>}
|
||||
show={
|
||||
<Typography
|
||||
component='h2'
|
||||
variant='h3'
|
||||
sx={(theme) => ({ marginBottom: theme.spacing(2) })}
|
||||
>
|
||||
{sectionTitle}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={projects.length < 1 && !loading}
|
||||
@ -124,6 +122,7 @@ export const ProjectGroup: React.FC<{
|
||||
project.featureCount
|
||||
}
|
||||
isFavorite={project.favorite}
|
||||
owners={project.owners}
|
||||
/>
|
||||
</StyledCardLink>
|
||||
))}
|
||||
@ -133,6 +132,6 @@ export const ProjectGroup: React.FC<{
|
||||
</StyledItemsContainer>
|
||||
}
|
||||
/>
|
||||
</StyledProjectGroupContainer>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
@ -28,10 +28,16 @@ import { groupProjects } from './group-projects';
|
||||
import { ProjectGroup } from './ProjectGroup';
|
||||
|
||||
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
||||
maxWidth: '400px',
|
||||
maxWidth: '500px',
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(4),
|
||||
}));
|
||||
|
||||
type PageQueryType = Partial<Record<'search', string>>;
|
||||
|
||||
type projectMap = {
|
||||
@ -148,12 +154,6 @@ export const ProjectListNew = () => {
|
||||
hasAccess(CREATE_PROJECT),
|
||||
);
|
||||
|
||||
const renderError = () => {
|
||||
return (
|
||||
<StyledApiError onClick={refetch} text='Error fetching projects' />
|
||||
);
|
||||
};
|
||||
|
||||
const projectCount =
|
||||
filteredProjects.length < projects.length
|
||||
? `${filteredProjects.length} of ${projects.length}`
|
||||
@ -172,6 +172,7 @@ export const ProjectListNew = () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
@ -219,24 +220,36 @@ export const ProjectListNew = () => {
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender condition={error} show={renderError()} />
|
||||
<ConditionallyRender
|
||||
condition={splitProjectList}
|
||||
show={
|
||||
<>
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='My projects'
|
||||
projects={groupedProjects.myProjects}
|
||||
<StyledContainer>
|
||||
<ConditionallyRender
|
||||
condition={error}
|
||||
show={() => (
|
||||
<StyledApiError
|
||||
onClick={refetch}
|
||||
text='Error fetching projects'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={splitProjectList}
|
||||
show={
|
||||
<>
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='My projects'
|
||||
projects={groupedProjects.myProjects}
|
||||
/>
|
||||
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='Other projects'
|
||||
projects={groupedProjects.otherProjects}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
elseShow={<ProjectGroupComponent projects={filteredProjects} />}
|
||||
/>
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='Other projects'
|
||||
projects={groupedProjects.otherProjects}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
elseShow={
|
||||
<ProjectGroupComponent projects={filteredProjects} />
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { ProjectStatsSchema } from 'openapi';
|
||||
import type { ProjectSchema, ProjectStatsSchema } from 'openapi';
|
||||
import type { IFeatureToggleListItem } from './featureToggle';
|
||||
import type { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef';
|
||||
import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnterpriseSettingsForm';
|
||||
@ -13,6 +13,7 @@ export interface IProjectCard {
|
||||
mode: string;
|
||||
memberCount?: number;
|
||||
favorite?: boolean;
|
||||
owners?: ProjectSchema['owners'];
|
||||
}
|
||||
|
||||
export type FeatureNamingType = {
|
||||
|
@ -28,6 +28,7 @@ const theme = {
|
||||
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
|
||||
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
|
||||
separator: '0px 2px 4px rgba(32, 32, 33, 0.12)', // Notifications header
|
||||
accordionFooter: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Sen, Roboto, sans-serif',
|
||||
|
@ -20,6 +20,7 @@ export const theme = {
|
||||
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
|
||||
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
|
||||
separator: '0px 2px 4px rgba(32, 32, 33, 0.12)', // Notifications header
|
||||
accordionFooter: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Sen, Roboto, sans-serif',
|
||||
|
@ -34,6 +34,7 @@ declare module '@mui/material/styles' {
|
||||
popup: string;
|
||||
primaryHeader: string;
|
||||
separator: string;
|
||||
accordionFooter: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -207,16 +207,14 @@ export default class ProjectController extends Controller {
|
||||
projectsSchema.$id,
|
||||
{ version: 1, projects: serializeDates(projectsWithOwners) },
|
||||
);
|
||||
|
||||
return;
|
||||
} else {
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
projectsSchema.$id,
|
||||
{ version: 1, projects: serializeDates(projects) },
|
||||
);
|
||||
}
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
projectsSchema.$id,
|
||||
{ version: 1, projects: serializeDates(projects) },
|
||||
);
|
||||
}
|
||||
|
||||
async getDeprecatedProjectOverview(
|
||||
|
@ -4,6 +4,11 @@ import { type IUser, RoleName, type IGroup } from '../../types';
|
||||
import { randomId } from '../../util';
|
||||
import { ProjectOwnersReadModel } from './project-owners-read-model';
|
||||
|
||||
jest.mock('../../util', () => ({
|
||||
...jest.requireActual('../../util'),
|
||||
generateImageUrl: jest.fn((input) => `https://${input.image_url}`),
|
||||
}));
|
||||
|
||||
const mockProjectWithCounts = (name: string) => ({
|
||||
name,
|
||||
id: name,
|
||||
@ -170,7 +175,7 @@ describe('integration tests', () => {
|
||||
ownerType: 'user',
|
||||
name: 'Owner Name',
|
||||
email: 'owner@email.com',
|
||||
imageUrl: 'image-url-1',
|
||||
imageUrl: 'https://image-url-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -248,7 +253,7 @@ describe('integration tests', () => {
|
||||
[projectId]: [
|
||||
{
|
||||
email: 'owner@email.com',
|
||||
imageUrl: 'image-url-1',
|
||||
imageUrl: 'https://image-url-1',
|
||||
name: 'Owner Name',
|
||||
ownerType: 'user',
|
||||
},
|
||||
@ -299,13 +304,13 @@ describe('integration tests', () => {
|
||||
[projectId]: [
|
||||
{
|
||||
email: 'owner2@email.com',
|
||||
imageUrl: 'image-url-3',
|
||||
imageUrl: 'https://image-url-3',
|
||||
name: 'Second Owner Name',
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
email: 'owner@email.com',
|
||||
imageUrl: 'image-url-1',
|
||||
imageUrl: 'https://image-url-1',
|
||||
name: 'Owner Name',
|
||||
ownerType: 'user',
|
||||
},
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Db } from '../../db/db';
|
||||
import { RoleName, type IProjectWithCount } from '../../types';
|
||||
import { generateImageUrl } from '../../util';
|
||||
import type {
|
||||
GroupProjectOwner,
|
||||
IProjectOwnersReadModel,
|
||||
@ -37,6 +38,7 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
|
||||
): Promise<Record<string, UserProjectOwner[]>> {
|
||||
const usersResult = await this.db
|
||||
.select(
|
||||
'user.id',
|
||||
'user.username',
|
||||
'user.name',
|
||||
'user.email',
|
||||
@ -58,7 +60,7 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
|
||||
ownerType: 'user',
|
||||
name: user?.name || user?.username,
|
||||
email: user?.email,
|
||||
imageUrl: user?.image_url,
|
||||
imageUrl: generateImageUrl(user),
|
||||
};
|
||||
|
||||
if (project in usersDict) {
|
||||
|
Loading…
Reference in New Issue
Block a user