diff --git a/src/components/values/containers/ContainerControl.tsx b/src/components/values/containers/ContainerControl.tsx index 939fe47cd383e2e9c154cb6f0151893eebb4863e..2d690df919fb2545675a2f458e3cdb8470728b50 100644 --- a/src/components/values/containers/ContainerControl.tsx +++ b/src/components/values/containers/ContainerControl.tsx @@ -1,10 +1,12 @@ import { + Box, FormControl, FormControlProps, FormHelperText, InputLabel, Stack, styled, + useTheme, } from '@mui/material' import {FocusEvent, PropsWithChildren, useCallback} from 'react' @@ -13,21 +15,36 @@ import PropsProvider from '../utils/PropsProvider' import useProps from '../utils/useProps' import ContainerProps, {ControlProps} from './ContainerProps' -const InputLabelWithAdornments = styled(InputLabel)(({theme}) => ({ +const LabelContainer = styled(Box)(({theme}) => ({ + position: 'absolute', + marginTop: theme.spacing(0.5), + height: theme.spacing(2), + width: '100%', display: 'flex', flexDirection: 'row', - alignItems: 'center', flexWrap: 'nowrap', + flexFlow: 'flex-start', + alignItems: 'center', +})) + +const StaticInputLabel = styled(InputLabel)(() => ({ + position: 'static', // normal "shrink" will not work anymore but was already disabled + transform: 'none', // overrides shrink transformations to help position label actions + overflow: 'visible', // otherwise label is clipped for short inputs textOverflow: 'ellipsis', - overflow: 'visible', - '& div:first-of-type': { - marginLeft: theme.spacing(1), - }, + fontSize: '0.75rem', +})) + +const LabelActionsContainer = styled(Box)(({theme}) => ({ + position: 'static', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& .MuiIconButton-root': {}, '& .MuiChip-root': { - marginTop: 2, marginLeft: theme.spacing(0.5), + height: 'fit-content', }, - '& .MuiIconButton-root': {}, })) export type ValueFormControlProps = ControlProps & @@ -48,8 +65,9 @@ export function ValueFormControl({...props}: ValueFormControlProps) { edit, ...formControlProps } = props - const {inputId, editable} = useProps<ContainerProps>() + const theme = useTheme() + return ( <FormControl fullWidth={fullWidth} @@ -60,14 +78,17 @@ export function ValueFormControl({...props}: ValueFormControlProps) { {...formControlProps} > {label && ( - <InputLabelWithAdornments - shrink - id={editable ? undefined : inputId && `${inputId}-label`} - htmlFor={editable ? inputId || undefined : undefined} - > - {label} - {labelActions} - </InputLabelWithAdornments> + <LabelContainer> + <StaticInputLabel + shrink + id={editable ? undefined : inputId && `${inputId}-label`} + htmlFor={editable ? inputId : undefined} + style={{marginLeft: editable ? theme.spacing(1.5) : 0}} + > + {label} + </StaticInputLabel> + <LabelActionsContainer>{labelActions}</LabelActionsContainer> + </LabelContainer> )} <Stack direction='row' width='100%'> {children} diff --git a/src/components/values/containers/Value.stories.tsx b/src/components/values/containers/Value.stories.tsx index 9f4f6befa5cf6cb4d5342f14092824209b0df7ea..00204746b0bb6c43dd8c76d6da34c68bda762dd5 100644 --- a/src/components/values/containers/Value.stories.tsx +++ b/src/components/values/containers/Value.stories.tsx @@ -44,6 +44,22 @@ export const InitialWidth: Story = { }, } +export const InitialWidthWithShortValueAndLongLabel: Story = { + args: { + fullWidth: false, + value: 'X', + editable: false, + label: 'label', + labelActions: ( + <> + <Chip label='small' size='small' /> + <Chip label='medium' size='medium' /> + <CopyToClipboard /> + </> + ), + }, +} + export const FormControl: Story = { args: { helperText: 'Helper text', diff --git a/src/pages/groups/GroupEditor.tsx b/src/pages/groups/GroupEditor.tsx index e6cc1ba2cf230ce332557221a3a7befb90d7548b..98f2db6887c96b428836fb4fb54b25a7e9f9ebbb 100644 --- a/src/pages/groups/GroupEditor.tsx +++ b/src/pages/groups/GroupEditor.tsx @@ -32,11 +32,6 @@ export default function GroupEditor({editable = false}) { label: 'group name', editable: editable, value: data.group_name, - component: { - Text: { - variant: 'standard', - }, - }, }, { grow: true, diff --git a/src/pages/groups/GroupPage.test.tsx b/src/pages/groups/GroupPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57108db6327e2b7dd3573ac27d4dd176e9a52099 --- /dev/null +++ b/src/pages/groups/GroupPage.test.tsx @@ -0,0 +1,52 @@ +import {screen, waitFor, within} from '@testing-library/react' +import {expect, it, vi} from 'vitest' + +import {GraphResponse} from '../../models/graphResponseModels' +import * as api from '../../utils/api' +import { + checkRow, + importLazyComponents, + renderWithRouteData, +} from '../../utils/test.helper' +import groupRoute from './groupRoute' + +await importLazyComponents(groupRoute) + +describe('GroupPage', () => { + it('loads and initially renders group editor', async () => { + const mockedApi = vi.spyOn(api, 'graphApi') + window.history.replaceState(null, '', '/group1') + mockedApi.mockResolvedValue({ + group1: { + group_name: 'Group 1', + group_id: 'group1', + owner: {user_id: 'user0', name: 'User 0', affiliation: 'Uni 0'}, + members: [ + {user_id: 'user0', name: 'User 0', affiliation: 'Uni 0'}, + {user_id: 'user1', name: 'User 1', affiliation: 'Uni 1'}, + {user_id: 'user2', name: 'User 2', affiliation: 'Uni 2'}, + ], + }, + } as GraphResponse) + await renderWithRouteData(groupRoute) + await waitFor(() => + expect(screen.getByLabelText('group name')).toHaveTextContent('Group 1'), + ) + + expect(screen.getByLabelText('owner')).toHaveTextContent('User 0') + expect(screen.getByLabelText('members')).toHaveTextContent('3') + expect(screen.getByLabelText('group id')).toHaveTextContent('group1') + + const table = screen.getByRole('table') + const rows = within(table).getAllByRole('row') + expect(rows).toHaveLength(4) + checkRow( + rows[0], + ['Name', 'User ID', 'Affiliation', 'Role'], + 'columnheader', + ) + checkRow(rows[1], ['User 0', 'user0', 'Uni 0', 'Owner']) + checkRow(rows[2], ['User 1', 'user1', 'Uni 1', 'Member']) + checkRow(rows[3], ['User 2', 'user2', 'Uni 2', 'Member']) + }) +}) diff --git a/src/pages/groups/GroupsPage.tsx b/src/pages/groups/GroupsPage.tsx index 04b4f878e9031e9e1434561a1c5c12cff51f9d86..255b01b2659e2423539a4b0beffcff19c2d60fb2 100644 --- a/src/pages/groups/GroupsPage.tsx +++ b/src/pages/groups/GroupsPage.tsx @@ -1,7 +1,7 @@ import useSelect from '../../components/navigation/useSelect' import Page, {PageActions} from '../../components/page/Page' import GroupsActions from './GroupsActions' -import {GroupsTable} from './GroupsTable' +import GroupsTable from './GroupsTable' import GroupsTableFilterMenu from './GroupsTableFilterMenu' export default function GroupsPage() { diff --git a/src/pages/groups/GroupsTable.test.tsx b/src/pages/groups/GroupsTable.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e1432ef6a565ccc859d91659129129658b525c8a --- /dev/null +++ b/src/pages/groups/GroupsTable.test.tsx @@ -0,0 +1,66 @@ +import {render, screen, within} from '@testing-library/react' +import {describe, expect, it, vi} from 'vitest' + +import GroupsTable from './GroupsTable' + +const setPaginate = vi.fn() +const navigate = vi.fn() + +vi.mock('../../components/routing/usePagination', () => ({ + validatePaginationSearch: () => {}, + default: () => [ + { + page: 1, + per_page: 10, + page_size: 12, + total: 100, + }, + setPaginate, + ], +})) + +vi.mock('../../components/routing/useRoute', () => ({ + default: () => ({ + navigate, + search: {}, + url: () => '/', + response: Array.from({length: 12}, (_, index) => ({ + group_id: `group${index}`, + group_name: `Group ${index}`, + owner: {user_id: `owner${index}`, name: `Owner ${index}`}, + members: [ + {user_id: `owner${index}`, name: `Owner ${index}`}, + {user_id: `member${index}`, name: `Member ${index}`}, + ], + })), + }), +})) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('GroupsTable Component', () => { + it('should render data, sort by name, request more data', async () => { + render(<GroupsTable />) + + const table = screen.getByRole('table') + expect(table).toBeVisible() + + const rows = within(table).getAllByRole('row') + expect(rows).toHaveLength(13) + + const sortNameButton = screen.getByRole('button', { + name: 'Sort by Name ascending', + }) + await sortNameButton.click() + expect(setPaginate).toHaveBeenCalledWith({ + order: 'asc', + order_by: 'group_name', + }) + + const nextPageButton = screen.getByText('Load More') + await nextPageButton.click() + expect(setPaginate).toHaveBeenCalledWith({page_size: 12 + 50}) + }) +}) diff --git a/src/pages/groups/GroupsTable.tsx b/src/pages/groups/GroupsTable.tsx index 9f7fd2db0a22e1635ac8f494ab59d3abdbf56173..8b53791161a216793fcdfa69e72257842bcf180a 100644 --- a/src/pages/groups/GroupsTable.tsx +++ b/src/pages/groups/GroupsTable.tsx @@ -20,7 +20,7 @@ function getUserRole(group: GroupResponse, userId: string | null | undefined) { } } -export function GroupsTable() { +export default function GroupsTable() { const {user} = useAuth() const {navigate} = useRoute() const data = useRouteData(groupsRoute) diff --git a/src/pages/groups/groupsRoute.tsx b/src/pages/groups/groupsRoute.tsx index ce44e84a6f5a5b668fbad6f89985a9fbc4d13d0e..7bfdc4806e2efe9d035df5238ff5a984c70ac263 100644 --- a/src/pages/groups/groupsRoute.tsx +++ b/src/pages/groups/groupsRoute.tsx @@ -1,5 +1,8 @@ import {Route} from '../../components/routing/types' -import {validatePaginationSearch} from '../../components/routing/usePagination' +import { + createPaginationRequest, + validatePaginationSearch, +} from '../../components/routing/usePagination' import {GroupsRequest} from '../../models/graphRequestModels' import {GroupsResponse} from '../../models/graphResponseModels' import {PageBasedPagination} from '../../utils/types' @@ -26,10 +29,7 @@ const groupsRoute: Route< isLeaf ? { m_request: { - pagination: { - page: search.page, - page_size: search.page_size, - }, + pagination: createPaginationRequest(search), query: { ...(search.user_id && {user_id: search.user_id}), }, diff --git a/src/utils/test.helper.tsx b/src/utils/test.helper.tsx index 764d737552d94b17dd434b406d9a47a0925c4a64..85fcc2e80b02ae8c24b909a4e484dd65026e30ed 100644 --- a/src/utils/test.helper.tsx +++ b/src/utils/test.helper.tsx @@ -1,5 +1,5 @@ import {ThemeProvider, createTheme} from '@mui/material/styles' -import {render as rtlRender} from '@testing-library/react' +import {render as rtlRender, within} from '@testing-library/react' import mediaQuery from 'css-mediaquery' import React, {ReactElement, ReactNode} from 'react' import {vi} from 'vitest' @@ -84,3 +84,15 @@ const customRender = (ui: ReactElement, options = {}) => // eslint-disable-next-line react-refresh/only-export-components export * from '@testing-library/react' export {customRender as render} + +type tableCellRole = 'cell' | 'columnheader' | 'rowheader' + +export const checkRow = ( + row: HTMLElement, + texts: string[], + role: tableCellRole = 'cell', +) => { + const cells = within(row).getAllByRole(role) + expect(cells).toHaveLength(texts.length) + cells.forEach((cell, i) => expect(cell).toHaveTextContent(texts[i])) +}