Compare commits

...

11 Commits

Author SHA1 Message Date
Gaurav Tewari
95ed4d126a chore: remove comment 2026-07-27 20:07:14 +05:30
Gaurav Tewari
8b488a5f89 chore: add comment 2026-07-27 20:06:05 +05:30
Gaurav Tewari
1785774a42 chore: remove comment 2026-07-27 20:03:06 +05:30
Gaurav Tewari
1210415e00 chore: remove comment 2026-07-27 20:00:03 +05:30
Gaurav Tewari
dc399e64dc chore: chore: remove comment 2026-07-27 19:53:27 +05:30
Gaurav Tewari
972171493e chore: add test id 2026-07-27 19:47:55 +05:30
Gaurav Tewari
94034a1c1c feat: add e2e for flows 2026-07-27 18:22:29 +05:30
Gaurav Tewari
77e09040bf chore: remove extra comments 2026-07-27 09:06:59 +05:30
Gaurav Tewari
a63bc71c6b chore: remove comments 2026-07-25 13:45:52 +05:30
Gaurav Tewari
056e48a265 feat(logs): highlight the URL-linked (activeLogId) row in table format (#12271)
Restores the linked-row highlight that the TanStack migration (#10946)
dropped from the table view, without coupling it to click routing.

Uses the table's existing `getRowClassName` hook (styling-only) — no new
shared-table prop:
- LogsExplorerList / LiveLogsList set getRowClassName to 'logs-linked-row'
  when `log.id === activeLogId`.
- A global, lowest-specificity rule paints `.logs-linked-row td` using the
  per-row `--row-active-bg` var (already set via getRowStyle). The table's own
  :hover / .tableRowActive cell rules are higher-specificity !important, so
  active/hover correctly win when a linked row is also open or hovered.

`isRowActive` stays `activeLog?.id` only, so the highlighted row still opens on
first click (relies on the routing fix in the base PR).

Color reuses --row-active-bg as a placeholder; a distinct "linked" token is a
follow-up pending design.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-25 12:59:32 +05:30
Gaurav Tewari
25cc65ace2 fix(logs): open log details in table (Column) format when activeId is set
Opening a log link (?activeLogId=...) and switching to Column/table format
left the highlighted row un-openable — clicking it did nothing.

Root cause: the shared TanStackTable decided open vs. close internally from
`isRowActive` + `onRowDeactivate`. LogsExplorerList had overloaded `isRowActive`
with `|| log.id === activeLogId`, so a URL-highlighted-but-closed row looked
"active" and the first click ran the deactivate (close) path instead of opening.

Fix (PR 1 of 2): move click routing to the consumer.
- Remove `onRowDeactivate` from TanStackTable; it took no args and only fired
  when re-clicking the same active row (clicking A→B never deactivated A).
- `onRowClick` now receives a third `{ isActive }` context arg (additive; the
  ~14 existing consumers are unaffected).
- LogsExplorerList and LiveLogsList own the toggle:
  `isActive ? handleCloseLogDetail() : handleSetActiveLog(log)`, and
  `isRowActive` tracks only `activeLog?.id`.

The linked-row visual highlight (`isHighlighted`) is deferred to PR 2 (blocked
on a design decision for the highlight color); see
frontend/docs/tdd-tanstack-row-click-routing.md.
2026-07-24 22:22:04 +05:30
14 changed files with 275 additions and 46 deletions

View File

@@ -438,7 +438,11 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip

View File

@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
{...props}
className={rowClassName}
style={rowStyle}
data-testid={context?.getRowTestId?.(rowData)}
onMouseEnter={handleMouseEnter}
onMouseLeave={clearHovered}
>
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
return false;
}
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
if (prevTestId !== nextTestId) {
return false;
}
const prevStyle = prev.context?.getRowStyle?.(prevData);
const nextStyle = next.context?.getRowStyle?.(nextData);
if (prevStyle !== nextStyle) {

View File

@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
}
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
onRowClick?.(rowData, itemKey, { isActive });
},
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
);
if (itemKind === 'expansion') {
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&

View File

@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
getGroupKey,
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
() => ({
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
[
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,

View File

@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
isActive: false,
});
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
it('calls onRowClick with isActive: true when the row is active', async () => {
// The table no longer owns open/close — it reports the active state and the
// consumer routes the click. An active row must still fire onRowClick.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: true,
});
});
it('calls onRowClick with isActive: false when the row is not active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
isRowActive: () => false,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('does not render renderRowActions before hover', () => {

View File

@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
{ isActive: false },
);
});
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
{ isActive: false },
);
});
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowDeactivate when clicking active row', async () => {
it('calls onRowClick with isActive: true when clicking the active row', async () => {
// The consumer owns open/close routing — the table just reports the
// active state via the click context.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
await user.click(screen.getByText('Item 1'));
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.anything(),
{ isActive: true },
);
});
it('opens in new tab on ctrl+click', async () => {

View File

@@ -116,9 +116,11 @@ export * from './useTableParams';
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* // The table reports the click + the row's active state; the consumer owns open/close.
* onRowClick={(row, itemKey, { isActive }) =>
* setSelectedId(isActive ? undefined : itemKey)
* }
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}

View File

@@ -81,15 +81,19 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type RowClickContext = {
isActive: boolean;
};
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
getRowTestId?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
getGroupKey?: (row: TData) => Record<string, string>;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
getRowTestId?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -60,3 +60,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -204,6 +204,9 @@ function LiveLogsList({
data={formattedLogs}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -216,10 +219,13 @@ function LiveLogsList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

@@ -323,3 +323,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -211,9 +211,11 @@ function LogsExplorerList({
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowTestId={(log): string => `logs-table-row-${log.id}`}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -226,10 +228,13 @@ function LogsExplorerList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
@@ -282,6 +287,7 @@ function LogsExplorerList({
options.maxLines,
options.fontSize,
activeLogIndex,
activeLogId,
logs,
onEndReached,
getItemContent,

View File

@@ -0,0 +1,112 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { dismissQuickFiltersAnnouncement } from './quick-filters';
const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
const RELATIVE_TIME = '6h';
const FORMAT_OPTIONS_TEST_ID = 'periscope-btn-format-options';
const ROW_TEST_ID_PREFIX = 'logs-table-row-';
const LOG_DETAIL_DRAWER_TEST_ID = 'log-detail-drawer';
export const LINKED_ROW_CLASS = 'logs-linked-row';
type LogsFormat = 'Raw' | 'Default' | 'Column';
const FORMAT_MODES: Record<LogsFormat, string> = {
Raw: 'raw',
Default: 'list',
Column: 'table',
};
export async function gotoLogsExplorer(page: Page): Promise<void> {
await dismissQuickFiltersAnnouncement(page);
await page.goto(`${LOGS_EXPLORER_PATH}?relativeTime=${RELATIVE_TIME}`);
await expect(page.getByTestId(FORMAT_OPTIONS_TEST_ID)).toBeVisible();
}
export async function setLogsFormat(
page: Page,
format: LogsFormat,
): Promise<void> {
const trigger = page.getByTestId(FORMAT_OPTIONS_TEST_ID);
const popover = page.locator('.format-options-popover');
const appliedMode = new RegExp(`%22format%22%3A%22${FORMAT_MODES[format]}%22`);
await expect(async () => {
if (!(await popover.isVisible())) {
await trigger.click();
await expect(popover).toBeVisible();
}
await popover
.locator('.menu-items .item')
.filter({ hasText: format })
.click();
await expect(page).toHaveURL(appliedMode, { timeout: 3_000 });
}).toPass({ timeout: 20_000 });
await trigger.click();
await expect(popover).toBeHidden();
}
export function logsTableRows(page: Page): Locator {
return page.locator(`[data-testid^="${ROW_TEST_ID_PREFIX}"]`);
}
export function logsTableRow(page: Page, logId: string): Locator {
return page.getByTestId(`${ROW_TEST_ID_PREFIX}${logId}`);
}
export function highlightedLogsTableRows(page: Page): Locator {
return logsTableRows(page).and(page.locator(`.${LINKED_ROW_CLASS}`));
}
export function unhighlightedLogsTableRows(page: Page): Locator {
return page.locator(
`[data-testid^="${ROW_TEST_ID_PREFIX}"]:not(.${LINKED_ROW_CLASS})`,
);
}
export function activeLogIdFromUrl(page: Page): string {
const value = new URL(page.url()).searchParams.get('activeLogId');
if (!value) {
throw new Error(`No activeLogId in URL: ${page.url()}`);
}
return value.replace(/"/g, '');
}
export function logDetailsDrawer(page: Page): Locator {
return page.getByTestId(LOG_DETAIL_DRAWER_TEST_ID);
}
export async function clickRowFirstCell(row: Locator): Promise<void> {
await row.locator('td').first().click();
}
export async function openLogDetailsFromRow(row: Locator): Promise<void> {
await clickRowFirstCell(row);
await expect(logDetailsDrawer(row.page())).toBeVisible();
}
export async function openContextView(page: Page): Promise<void> {
await page
.locator('.views-tabs')
.getByText('Context', { exact: true })
.click();
await expect(contextLogItems(page).first()).toBeVisible();
}
function contextLogItems(page: Page): Locator {
return page.locator('.context-log-renderer__item');
}
export async function openFirstContextLogInNewTab(page: Page): Promise<Page> {
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
contextLogItems(page).first().click(),
]);
await newPage.waitForLoadState();
return newPage;
}

View File

@@ -0,0 +1,49 @@
import { expect, test } from '../../fixtures/auth';
import {
activeLogIdFromUrl,
clickRowFirstCell,
gotoLogsExplorer,
highlightedLogsTableRows,
LINKED_ROW_CLASS,
logDetailsDrawer,
logsTableRow,
logsTableRows,
openFirstContextLogInNewTab,
openContextView,
openLogDetailsFromRow,
setLogsFormat,
unhighlightedLogsTableRows,
} from '../../helpers/logs-explorer';
test.describe('Logs Explorer — linked row in Column format', () => {
test('TC-01 the linked row in a newly opened tab is highlighted and toggles the details drawer on click', async ({
authedPage: page,
}) => {
await gotoLogsExplorer(page);
await setLogsFormat(page, 'Column');
const rows = logsTableRows(page);
await expect(rows.first()).toBeVisible();
await expect(highlightedLogsTableRows(page)).toHaveCount(0);
await openLogDetailsFromRow(rows.first());
await openContextView(page);
const linkedTab = await openFirstContextLogInNewTab(page);
await expect(linkedTab).toHaveURL(/activeLogId=/);
const linkedLogId = activeLogIdFromUrl(linkedTab);
const linkedRow = logsTableRow(linkedTab, linkedLogId);
await expect(linkedRow).toBeVisible();
await expect(linkedRow).toHaveClass(new RegExp(LINKED_ROW_CLASS));
await expect(highlightedLogsTableRows(linkedTab)).toHaveCount(1);
await expect(unhighlightedLogsTableRows(linkedTab).first()).toBeVisible();
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
await openLogDetailsFromRow(linkedRow);
await clickRowFirstCell(linkedRow);
await expect(logDetailsDrawer(linkedTab)).toBeHidden();
});
});