1
0
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:
Tymoteusz Czech 2024-04-29 11:51:44 +02:00 committed by GitHub
parent 3978c690e0
commit b6865a5a9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 428 additions and 265 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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

View File

@ -25,6 +25,9 @@ interface IGroupCardAvatarsProps {
users: IGroupUser[];
}
/**
* @deprecated Remove after with `projectsListNewCards` flag
*/
export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
const shownUsers = useMemo(
() =>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
),

View File

@ -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;

View File

@ -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),

View File

@ -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),
}));

View File

@ -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>
);

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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),

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 = {

View File

@ -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',

View File

@ -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',

View File

@ -34,6 +34,7 @@ declare module '@mui/material/styles' {
popup: string;
primaryHeader: string;
separator: string;
accordionFooter: string;
};
}

View File

@ -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(

View File

@ -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',
},

View File

@ -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) {