diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx index adb8abfb4b..584c8065da 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx @@ -19,13 +19,14 @@ const StyledAvatars = styled('div')(({ theme }) => ({ 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 StyledAvatar = (component: typeof UserAvatar) => + styled(component)(({ theme }) => ({ + outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`, + marginLeft: theme.spacing(-1), + '&:hover': { + outlineColor: theme.palette.primary.main, + }, + })); const StyledHeader = styled('h3')(({ theme }) => ({ margin: theme.spacing(0, 0, 1), @@ -42,13 +43,31 @@ interface IGroupCardAvatarsProps { }[]; header?: ReactNode; avatarLimit?: number; + AvatarComponent?: typeof UserAvatar; } export const GroupCardAvatars = ({ + AvatarComponent, + ...props +}: IGroupCardAvatarsProps) => { + const Avatar = StyledAvatar(AvatarComponent ?? UserAvatar); + + return ; +}; + +type GroupCardAvatarsInnerProps = Omit< + IGroupCardAvatarsProps, + 'AvatarComponent' +> & { + AvatarComponent: typeof UserAvatar; +}; + +const GroupCardAvatarsInner = ({ users = [], header = null, avatarLimit = 9, -}: IGroupCardAvatarsProps) => { + AvatarComponent, +}: GroupCardAvatarsInnerProps) => { const shownUsers = useMemo( () => users @@ -94,7 +113,7 @@ export const GroupCardAvatars = ({ /> {shownUsers.map((user) => ( - { @@ -107,9 +126,9 @@ export const GroupCardAvatars = ({ avatarLimit} show={ - + +{users.length - shownUsers.length} - + } /> prop !== 'avatarWidth', -})<{ avatarWidth?: (theme: Theme) => string }>(({ theme, avatarWidth }) => { - const width = avatarWidth ? avatarWidth(theme) : theme.spacing(3.5); - - return { - width, - height: width, - margin: 'auto', - backgroundColor: theme.palette.secondary.light, - color: theme.palette.text.primary, - fontSize: theme.fontSizes.smallerBody, - fontWeight: theme.fontWeight.bold, - }; -}); +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(3.5), + height: theme.spacing(3.5), + margin: 'auto', + backgroundColor: theme.palette.secondary.light, + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + fontWeight: theme.fontWeight.bold, +})); export interface IUserAvatarProps extends AvatarProps { user?: Partial< @@ -35,7 +29,6 @@ export interface IUserAvatarProps extends AvatarProps { onMouseLeave?: () => void; className?: string; sx?: SxProps; - avatarWidth?: (theme: Theme) => string; hideTitle?: boolean; } diff --git a/frontend/src/component/feature/FeatureView/Collaborators.test.tsx b/frontend/src/component/feature/FeatureView/Collaborators.test.tsx new file mode 100644 index 0000000000..c47e07d690 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/Collaborators.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { Collaborators } from './Collaborators'; + +test('renders nothing if collaborators is undefined', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); +}); + +test('renders nothing if users is empty', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); +}); diff --git a/frontend/src/component/feature/FeatureView/Collaborators.tsx b/frontend/src/component/feature/FeatureView/Collaborators.tsx new file mode 100644 index 0000000000..95cc6e92d3 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/Collaborators.tsx @@ -0,0 +1,95 @@ +import { styled } from '@mui/material'; +import { GroupCardAvatars } from 'component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import type { Collaborator } from 'interfaces/featureToggle'; +import type { FC } from 'react'; +import { Link } from 'react-router-dom'; + +const StyledAvatar = styled(UserAvatar)(({ theme }) => ({ + width: theme.spacing(3), + height: theme.spacing(3), +})); + +const LastModifiedByContainer = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateAreas: ` + 'description description' + 'avatar link' + `, + rowGap: theme.spacing(0.5), + columnGap: theme.spacing(1), + alignItems: 'center', + height: 'min-content', +})); + +const GridDescription = styled('span')({ gridArea: 'description' }); +const GridTooltip = styled(HtmlTooltip)({ gridArea: 'avatar' }); +const GridLink = styled(Link)({ gridArea: 'link' }); + +const LastModifiedBy: FC = ({ id, name, imageUrl }) => { + return ( + + Last modified by + + + + + + view change + + ); +}; + +const CollaboratorListContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexFlow: 'column', + gap: theme.spacing(0.5), + alignItems: 'flex-start', + height: 'min-content', +})); + +const CollaboratorList: FC<{ collaborators: Collaborator[] }> = ({ + collaborators, +}) => { + return ( + + Collaborators + + + ); +}; + +const Container = styled('article')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(10), + alignItems: 'center', + justifyContent: 'space-between', + [theme.breakpoints.down('xl')]: { + display: 'none', + }, +})); + +type Props = { + collaborators: Collaborator[] | undefined; +}; + +export const Collaborators: FC = ({ collaborators }) => { + if (!collaborators || collaborators.length === 0) { + return null; + } + + const lastModifiedBy = collaborators[0]; + + return ( + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx index 94430d25c6..485f27e44d 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx @@ -50,6 +50,7 @@ import copy from 'copy-to-clipboard'; import useToast from 'hooks/useToast'; import { useUiFlag } from 'hooks/useUiFlag'; import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { Collaborators } from './Collaborators'; const StyledHeader = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.background.paper, @@ -113,8 +114,12 @@ const StyledSeparator = styled('div')(({ theme }) => ({ height: '1px', })); -const StyledTabContainer = styled('div')(({ theme }) => ({ - padding: theme.spacing(0, 4), +const StyledTabRow = styled('div')(({ theme }) => ({ + display: 'flex', + flexFlow: 'row nowrap', + gap: theme.spacing(4), + paddingInline: theme.spacing(4), + justifyContent: 'space-between', })); const StyledTabButton = styled(Tab)(({ theme }) => ({ @@ -155,6 +160,7 @@ export const FeatureView = () => { const [openStaleDialog, setOpenStaleDialog] = useState(false); const [isFeatureNameCopied, setIsFeatureNameCopied] = useState(false); const smallScreen = useMediaQuery(`(max-width:${500}px)`); + const showCollaborators = useUiFlag('featureCollaborators'); const { feature, loading, error, status } = useFeature( projectId, @@ -355,7 +361,7 @@ export const FeatureView = () => { - + { /> ))} - + + } + /> + } /> diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/AvatarCell.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/AvatarCell.tsx index d299ed68dd..4f9fcc5ede 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/AvatarCell.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/AvatarCell.tsx @@ -1,4 +1,4 @@ -import { type Theme, styled } from '@mui/material'; +import { styled } from '@mui/material'; import type { FC } from 'react'; import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly'; import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; @@ -35,6 +35,11 @@ const StyledSecondaryText = styled('p')(({ theme }) => ({ color: theme.palette.text.secondary, })); +const StyledAvatar = styled(UserAvatar)(({ theme }) => ({ + width: theme.spacing(3), + height: theme.spacing(3), +})); + export const AvatarCell = (onAvatarClick: (userId: number) => void): FC => ({ row: { original } }) => { @@ -67,14 +72,13 @@ export const AvatarCell = - theme.spacing(3)} /> diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index 3b97ce95d7..205cfcd366 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -38,6 +38,16 @@ export type Lifecycle = { enteredStageAt: string; }; +export type Collaborator = { + id: number; + name: string; + imageUrl: string; +}; + +export type CollaboratorData = { + users: Collaborator[]; +}; + /** * @deprecated use FeatureSchema from openapi */ @@ -65,6 +75,7 @@ export interface IFeatureToggle { name: string; imageUrl: string; }; + collaborators?: CollaboratorData; } export interface IDependency { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 09c5c630d0..c9e4705945 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -331,7 +331,7 @@ const flags: IFlags = { false, ), featureCollaborators: parseEnvVarBoolean( - process.env.UNEASH_EXPERIMENTAL_FEATURE_COLLABORATORS, + process.env.UNLEASH_EXPERIMENTAL_FEATURE_COLLABORATORS, false, ), };