Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion webapp/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ test.beforeEach('Login', async ({ page }) => {
await expect(page.getByText(/Login/)).toBeVisible();
await page.getByPlaceholder('ID').fill('foo');
await page.getByPlaceholder('Password').fill('bar');
const loginResponse = page.waitForResponse((response) => response.url().includes('/api/v1/login'));
await page.getByRole('button', { name: 'Login' }).click();
await loginResponse;
});

test('welcome message', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to Central Dogma!' })).toBeVisible();
});

Expand All @@ -18,7 +21,7 @@ test('search project', async ({ page }) => {

await expect(page.getByText('Search project ...')).toBeVisible();
await expect(page.getByRole('combobox')).toBeVisible();
await page.locator('#project-select').click();
await page.locator('#home-search').click();
await expect(page.getByRole('option', { name: 'dogma' })).toBeVisible();
await expect(page.getByRole('option', { name: 'foo' })).toBeVisible();
});
55 changes: 55 additions & 0 deletions webapp/e2e/project-owners.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';

test.beforeEach('Login', async ({ page }) => {
await page.goto('/');

await expect(page.getByText(/Login/)).toBeVisible();
await page.getByPlaceholder('ID').fill('foo');
await page.getByPlaceholder('Password').fill('bar');
const loginResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/login'));
await page.getByRole('button', { name: 'Login' }).click();
await loginResponsePromise;
});

test('view project members from list', async ({ page }) => {
await page.goto('/app/projects');

const projectRows = page.locator('tr', { has: page.getByRole('button', { name: 'View members' }) });
await expect(projectRows.first()).toBeVisible();
const rowCount = await projectRows.count();
let projectName = '';
let members: string[] = [];
let targetRowIndex = 0;
for (let i = 0; i < rowCount; i += 1) {
const row = projectRows.nth(i);
const candidateName = (await row.getByRole('link').first().innerText()).trim();
const metadataResponse = await page.request.get(`/api/v1/projects/${encodeURIComponent(candidateName)}`);
if (!metadataResponse.ok()) {
continue;
}
const metadata = await metadataResponse.json();
const candidateMembers = Object.entries(metadata.members).map(
([login, member]: [string, { login?: string }]) => member.login || login,
);
if (candidateMembers.length > 0) {
projectName = candidateName;
members = candidateMembers;
targetRowIndex = i;
break;
}
}
if (!projectName) {
const fallbackRow = projectRows.first();
projectName = (await fallbackRow.getByRole('link').first().innerText()).trim();
}

const projectRow = projectRows.nth(targetRowIndex);
await projectRow.getByRole('button', { name: 'View members' }).click();

const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Project members')).toBeVisible();
if (members.length > 0) {
await expect(dialog.getByTestId('project-member-login').first()).toBeVisible({ timeout: 15000 });
}
});
131 changes: 131 additions & 0 deletions webapp/src/dogma/features/project/ProjectOwnersModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { useGetMetadataByProjectNameQuery } from 'dogma/features/api/apiSlice';
import { FaCrown } from 'react-icons/fa';
import { FaUserGroup } from 'react-icons/fa6';

interface ProjectOwnersModalProps {
projectName: string | null;
isOpen: boolean;
onClose: () => void;
}

export const ProjectOwnersModal = ({ projectName, isOpen, onClose }: ProjectOwnersModalProps) => {
const {
data: ownersMetadata,
isLoading: isMetadataLoading,
isError,
error,
refetch,
} = useGetMetadataByProjectNameQuery(projectName ?? '', {
refetchOnFocus: true,
skip: !projectName,
});
const allMembers = ownersMetadata
? Object.entries(ownersMetadata.members).map(([login, member]) => ({
...member,
login: member.login || login,
}))
: [];
const owners = allMembers.filter((member) => member.role === 'OWNER');
const members = allMembers.filter((member) => member.role !== 'OWNER');
const errorMessage =
error && typeof error === 'object' && 'status' in error ? `Failed to load members. (${error.status})` : null;

return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Project members</ModalHeader>
<ModalCloseButton />
<ModalBody>
{isMetadataLoading ? (
<Text color="gray.500">Loading members...</Text>
) : isError ? (
<>
<Text color="red.500" mb={2}>
{errorMessage || 'Failed to load members.'}
</Text>
<Button size="sm" onClick={() => refetch()}>
Retry
</Button>
</>
) : owners.length === 0 ? (
<Text color="gray.600">System administrators</Text>
) : (
<>
{owners.length > 0 && (
<>
<Text fontWeight="semibold" mb={2}>
<FaCrown
style={{
marginRight: '8px',
display: 'inline-block',
color: '#3182ce',
marginTop: '1px',
marginBottom: '-1px',
}}
/>
Owners
</Text>
<UnorderedList spacing={2} mb={4} stylePosition="outside" pl={4}>
{owners.map((member) => (
<ListItem
key={member.login}
display="list-item"
sx={{ '::marker': { color: 'blue.500', fontWeight: 'bold' } }}
>
<Text data-testid="project-member-login" as="span" fontWeight="semibold">
{member.login}
</Text>
</ListItem>
))}
</UnorderedList>
</>
)}
{members.length > 0 && (
<>
<Text fontWeight="semibold" mb={2}>
<FaUserGroup
style={{
marginRight: '8px',
display: 'inline-block',
color: '#38a169',
marginTop: '1px',
marginBottom: '-1px',
}}
/>
Members
</Text>
<UnorderedList spacing={2} stylePosition="outside" pl={4}>
{members.map((member) => (
<ListItem
key={member.login}
display="list-item"
sx={{ '::marker': { color: 'green.500', fontWeight: 'bold' } }}
>
<Text data-testid="project-member-login" as="span" fontWeight="semibold">
{member.login}
</Text>
</ListItem>
))}
</UnorderedList>
</>
)}
</>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
41 changes: 38 additions & 3 deletions webapp/src/dogma/features/project/Projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ import {
MenuOptionGroup,
Spacer,
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
import { FcServices } from 'react-icons/fc';
import { ChakraLink } from 'dogma/common/components/ChakraLink';
import { ProjectDto } from 'dogma/features/project/ProjectDto';
import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination';
import { createColumnHelper } from '@tanstack/react-table';
import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { RestoreProject } from 'dogma/features/project/RestoreProject';
import { Deferred } from 'dogma/common/components/Deferred';
import { useAppDispatch, useAppSelector } from 'dogma/hooks';
Expand All @@ -46,6 +47,7 @@ import { FiBox } from 'react-icons/fi';
import { FaFilter, FaTrashAlt } from 'react-icons/fa';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { ProjectFilterType, setProjectFilter } from 'dogma/features/filter/filterSlice';
import { ProjectOwnersModal } from 'dogma/features/project/ProjectOwnersModal';
import { UserDto } from '../auth/UserDto';
import { UserRole } from '../../common/components/UserRole';

Expand All @@ -63,6 +65,8 @@ function filterProjects(projects: ProjectDto[], projectFilterType: ProjectFilter
export const Projects = () => {
const columnHelper = createColumnHelper<ProjectDto>();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
const [ownersProjectName, setOwnersProjectName] = useState<string | null>(null);

const { user, isInAnonymousMode } = useAppSelector((state) => state.auth);
const { projectFilter, isInitialProjectFilter } = useAppSelector(({ filter }) => filter);
Expand All @@ -73,7 +77,6 @@ export const Projects = () => {
} = useGetProjectsQuery({
systemAdmin: user?.systemAdmin || false,
});

let filteredProjects = projects;
if (!isInAnonymousMode && !isLoading && !error) {
filteredProjects = filterProjects(projects, projectFilter, user);
Expand All @@ -86,6 +89,7 @@ export const Projects = () => {
const columns = useMemo(
() => [
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'name',
cell: (info) =>
info.row.original.createdAt ? (
<ChakraLink href={`/app/projects/${info.getValue()}`} fontWeight="bold">
Expand Down Expand Up @@ -138,6 +142,29 @@ export const Projects = () => {
header: 'Created',
}),
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'members',
cell: (info) => {
if (!info.row.original.createdAt) {
return null;
}
return (
<Button
size="sm"
variant="outline"
onClick={() => {
setOwnersProjectName(info.getValue());
onOpen();
}}
>
View members
</Button>
);
},
header: 'Members',
enableSorting: false,
}),
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'action',
cell: (info) => {
if (isInternalProject(info.row.original.name)) {
return null;
Expand Down Expand Up @@ -175,7 +202,7 @@ export const Projects = () => {
enableSorting: false,
}),
],
[columnHelper, user],
[columnHelper, onOpen, user],
);
return (
<Deferred isLoading={isLoading} error={error}>
Expand Down Expand Up @@ -207,6 +234,14 @@ export const Projects = () => {
</Flex>
)}
<DataTableClientPagination columns={columns} data={filteredProjects} />
<ProjectOwnersModal
projectName={ownersProjectName}
isOpen={isOpen}
onClose={() => {
setOwnersProjectName(null);
onClose();
}}
/>
</Box>
)}
</Deferred>
Expand Down
Loading