Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ import { TableActions } from './components/TableActions';
import { CellContent } from './components/TableCells/CellContent';
import { ViewSettingsMenu } from './components/ViewSettingsMenu';

import type { Modules } from '@strapi/types';

const { INJECT_COLUMN_IN_TABLE } = HOOKS;

/* -------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -264,14 +262,6 @@ const ListViewPage = () => {
defaultMessage: 'Untitled',
});

const handleRowClick = (id: Modules.Documents.ID) => () => {
trackUsage('willEditEntryFromList');
navigate({
pathname: id.toString(),
search: stringify({ plugins: query.plugins }),
});
};

const isEmptyState = !isFetching && results.length === 0;

const endActions = (
Expand Down Expand Up @@ -393,28 +383,37 @@ const ListViewPage = () => {
<Table.Body>
{results.map((row) => {
return (
<Table.Row
cursor="pointer"
key={row.id}
onClick={handleRowClick(row.documentId)}
>
<Table.CheckboxCell id={row.id} />
{tableHeaders.map(({ cellFormatter, ...header }) => {
<LinkRow cursor="pointer" key={row.id}>
<Table.CheckboxCell
id={row.id}
style={{ position: 'relative', zIndex: 1 }}
/>
{tableHeaders.map(({ cellFormatter, ...header }, index) => {
const rowLink = index === 0 && (
<RowLink
to={{
pathname: row.documentId.toString(),
search: stringify({ plugins: query.plugins }),
}}
onClick={() => trackUsage('willEditEntryFromList')}
tabIndex={-1}
/>
);

if (header.name === 'status') {
const { status } = row;

return (
<Table.Cell key={header.name}>
{rowLink}
<DocumentStatus status={status} maxWidth={'min-content'} />
</Table.Cell>
);
}
if (['createdBy', 'updatedBy'].includes(header.name.split('.')[0])) {
// Display the users full name
// Some entries doesn't have a user assigned as creator/updater (ex: entries created through content API)
// In this case, we display a dash
return (
<Table.Cell key={header.name}>
{rowLink}
<Typography textColor="neutral800">
{row[header.name.split('.')[0]]
? getDisplayName(row[header.name.split('.')[0]])
Expand All @@ -426,13 +425,15 @@ const ListViewPage = () => {
if (typeof cellFormatter === 'function') {
return (
<Table.Cell key={header.name}>
{rowLink}
{/* @ts-expect-error – TODO: fix this TS error */}
{cellFormatter(row, header, { collectionType, model })}
</Table.Cell>
);
}
return (
<Table.Cell key={header.name}>
{rowLink}
<CellContent
content={row[header.name.split('.')[0]]}
rowId={row.documentId}
Expand All @@ -441,11 +442,10 @@ const ListViewPage = () => {
</Table.Cell>
);
})}
{/* we stop propagation here to allow the menu to trigger it's events without triggering the row redirect */}
<ActionsCell onClick={(e) => e.stopPropagation()}>
<TableActions document={row} />
</ActionsCell>
</Table.Row>
</LinkRow>
);
})}
</Table.Body>
Expand All @@ -466,9 +466,23 @@ const ListViewPage = () => {
);
};

const LinkRow = styled(Table.Row)`
position: relative;
`;

const RowLink = styled(ReactRouterLink)`
&::after {
content: '';
position: absolute;
inset: 0;
}
`;

const ActionsCell = styled(Table.Cell)`
display: flex;
justify-content: flex-end;
position: relative;
z-index: 1;
`;

/* -------------------------------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ const RelationMultiple = ({ mainField, content, rowId, name }: RelationMultipleP

return (
<Menu.Root onOpenChange={(isOpen) => setIsOpen(isOpen)}>
<Menu.Trigger onClick={(e) => e.stopPropagation()}>
<Menu.Trigger
onClick={(e) => e.stopPropagation()}
style={{ position: 'relative', zIndex: 1 }}
>
<Typography style={{ cursor: 'pointer' }} textColor="neutral800" fontWeight="regular">
{contentCount > 0
? formatMessage(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { render, screen, server } from '@tests/utils';
import { rest } from 'msw';
import { Route, Routes } from 'react-router-dom';

import { ListViewPage } from '../ListViewPage';

/**
* The ListViewPage uses the content-manager plugin's InjectionZone which
* depends on getPlugin() from StrapiAppProvider. The test providers mock
* getPlugin as a bare jest.fn(), so we mock InjectionZone to avoid the error.
*/
jest.mock('../../../components/InjectionZone', () => ({
InjectionZone: () => null,
}));

/**
* The ListViewPage is wrapped with DocumentRBAC in production (via ProtectedListViewPage).
* We mock useDocumentRBAC to avoid needing the full RBAC provider setup.
*/
jest.mock('../../../features/DocumentRBAC', () => ({
...jest.requireActual('../../../features/DocumentRBAC'),
useDocumentRBAC: jest.fn().mockReturnValue({ canCreate: true }),
}));

/**
* TableActions depends on the content-manager plugin's apis (getDocumentActions),
* which isn't registered in the test StrapiAppProvider. Mock it out.
*/
jest.mock('../components/TableActions', () => ({
TableActions: () => null,
}));

/**
* BulkActionsRenderer also depends on the content-manager plugin's apis (getBulkActions),
* which is triggered when rows are selected. Mock it out.
*/
jest.mock('../components/BulkActions/Actions', () => ({
BulkActionsRenderer: () => null,
}));

const setup = () => {
// Override the collection-types handler to include pagination
server.use(
rest.get('/content-manager/collection-types/:contentType', (req, res, ctx) => {
return res(
ctx.json({
results: [
{ documentId: '12345', id: 1, name: 'Entry 1', publishedAt: null },
{ documentId: '67890', id: 2, name: 'Entry 2', publishedAt: null },
{ documentId: 'abcde', id: 3, name: 'Entry 3', publishedAt: null },
],
pagination: { page: 1, pageSize: 10, pageCount: 1, total: 3 },
})
);
})
);

return render(<ListViewPage />, {
renderOptions: {
wrapper({ children }) {
return (
<Routes>
<Route path="/content-manager/:collectionType/:slug" element={children} />
</Routes>
);
},
},
initialEntries: ['/content-manager/collection-types/api::address.address'],
});
};

describe('ListViewPage', () => {
it('should render table rows with link elements for navigation', async () => {
setup();

// Wait for the table to render with data — the default list layout columns
// are ['id', 'categories', 'cover', 'postal_code'], so we wait for an id value
expect(await screen.findByText('3 entries found')).toBeInTheDocument();

// Verify that link elements are rendered in the table for each row.
// The RowLink uses React Router's Link which renders an <a> tag.
const links = screen.getAllByRole('link', { hidden: true });
const rowLinks = links.filter(
(link) =>
link.getAttribute('href')?.includes('12345') ||
link.getAttribute('href')?.includes('67890') ||
link.getAttribute('href')?.includes('abcde')
);

expect(rowLinks).toHaveLength(3);

// Verify each link href contains the correct documentId
expect(rowLinks[0]).toHaveAttribute('href', expect.stringContaining('12345'));
expect(rowLinks[1]).toHaveAttribute('href', expect.stringContaining('67890'));
expect(rowLinks[2]).toHaveAttribute('href', expect.stringContaining('abcde'));
});

it('should still allow checkbox selection without navigating', async () => {
const { user } = setup();

expect(await screen.findByText('3 entries found')).toBeInTheDocument();

// Click the first row's checkbox
const firstRowCheckbox = screen.getByRole('checkbox', { name: /select 1/i });
await user.click(firstRowCheckbox);

// The "1 row selected" text confirms selection worked and we're still on the list page
expect(screen.getByText(/1 row selected/i)).toBeInTheDocument();
});
});