Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e2a7092
feat(studio): anonymizer builder live preview panel [ASTD-332]
marcusds Jul 30, 2026
79f4108
fix(studio): clamp the replacement map page and drop the builder foot…
marcusds Jul 31, 2026
6947da0
fix(studio): keep Preview validation errors on the Source tab [ASTD-332]
marcusds Jul 31, 2026
8bc686b
feat(studio): swap Preview for Stop while a preview runs [ASTD-332]
marcusds Jul 31, 2026
4b86c26
fix(studio): render PII tags with Badge, as the design specifies [AST…
marcusds Jul 31, 2026
953209f
perf(studio): defer preview log renders and memoize the record view […
marcusds Jul 31, 2026
ceb2d05
refactor(studio): split out the shared record-view pieces [ASTD-332]
marcusds Jul 31, 2026
38fbc34
fix(studio): label the loading skeleton from the selected strategy [A…
marcusds Jul 31, 2026
df41377
refactor(studio): reuse existing helpers instead of the branch's copi…
marcusds Jul 31, 2026
9b80e0d
refactor(studio): use the generated frame types for preview [ASTD-332]
marcusds Aug 4, 2026
fe4fc89
fix(studio): correct anonymizer preview span mapping and run races [A…
marcusds Aug 5, 2026
fe83b2e
fix(studio): import MemoryRouter from react-router [ASTD-332]
marcusds Aug 5, 2026
5082b27
fix(studio): unicode offsets, preview row cap, map pagination [ASTD-332]
marcusds Aug 5, 2026
f92b34c
test(studio): cover the replacement map page rewind [ASTD-332]
marcusds Aug 5, 2026
b821931
Merge branch 'main' into astd-332-anonymizer-live-preview-panel/mschwab
marcusds Aug 5, 2026
dbb3736
fix(studio): drop spans that convert out of range [ASTD-332]
marcusds Aug 5, 2026
341c89f
perf(studio): hoist the preview log accordion out of render [ASTD-332]
marcusds Aug 5, 2026
7a08fed
refactor(studio): apply anonymizer record view review feedback [ASTD-…
marcusds Aug 5, 2026
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
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flex, Stack } from '@nvidia/foundations-react-core';
import { RecordSection } from '@studio/components/AnonymizerRecordView/RecordSection';
import { StackedSkeleton } from '@studio/components/StackedSkeleton';
import type { FC } from 'react';

const SKELETON_LINES = 8;

const SkeletonBlock: FC = () => (
<Stack gap="density-sm">
<StackedSkeleton count={SKELETON_LINES} />
</Stack>
);

interface AnonymizerRecordSkeletonProps {
readonly outputHeading: string;
}

export const AnonymizerRecordSkeleton: FC<AnonymizerRecordSkeletonProps> = ({ outputHeading }) => (
<Stack gap="density-2xl">
<Flex align="start" gap="density-2xl">
<RecordSection className="flex-1 min-w-0" heading="Original">
<SkeletonBlock />
</RecordSection>
<RecordSection className="flex-1 min-w-0" heading={outputHeading}>
<SkeletonBlock />
</RecordSection>
</Flex>
<RecordSection heading="Replacement Map">
<SkeletonBlock />
</RecordSection>
</Stack>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { AnonymizerRecordView } from '@studio/components/AnonymizerRecordView/AnonymizerRecordView';
import { buildAnonymizerRecord } from '@studio/components/AnonymizerRecordView/parse';
import { render, screen, waitFor } from '@testing-library/react';
import type { FC } from 'react';
import { MemoryRouter, useLocation } from 'react-router';

const traceRow = {
biography: 'Bobby, a 40-year-old veterinarian.',
biography_replaced: 'Teddy, a 45-year-old veterinarian.',
final_entities: {
entities: [
{ value: 'Bobby', label: 'first_name', start_position: 0, end_position: 5 },
{ value: '40', label: 'age', start_position: 9, end_position: 11 },
],
},
_replacement_map: {
replacements: [
{ original: 'Bobby', label: 'first_name', synthetic: 'Teddy' },
{ original: '40', label: 'age', synthetic: '45' },
],
},
};

const SearchProbe: FC = () => <span data-testid="search">{useLocation().search}</span>;

const renderRecord = (
row: Record<string, unknown>,
outputHeading = 'Replaced',
initialEntry = '/'
) =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<AnonymizerRecordView
outputHeading={outputHeading}
record={buildAnonymizerRecord(row, 'biography')}
/>
<SearchProbe />
</MemoryRouter>
);

describe('AnonymizerRecordView', () => {
it('tags detected entities in both columns', () => {
renderRecord(traceRow);

expect(screen.getAllByText('Original').length).toBeGreaterThan(0);
expect(screen.getByText('Replaced')).toBeInTheDocument();
expect(screen.getAllByText('Bobby').length).toBeGreaterThan(0);
expect(screen.getAllByText('first_name').length).toBeGreaterThan(0);
expect(screen.getAllByText('45').length).toBeGreaterThan(0);
});

it('lists every replacement in the map', () => {
renderRecord(traceRow);

expect(screen.getByText('Replacement Map')).toBeInTheDocument();
expect(screen.getAllByText('Teddy').length).toBeGreaterThan(0);
expect(screen.getAllByText('age').length).toBeGreaterThan(0);
});

it('uses the caller heading for the output column', () => {
renderRecord(traceRow, 'Rewritten');

expect(screen.getByText('Rewritten')).toBeInTheDocument();
});

it('still shows the map when the shared page param outruns a short record', () => {
renderRecord(traceRow, 'Replaced', '/?page=3');

expect(screen.getAllByText('Teddy').length).toBeGreaterThan(0);
expect(screen.queryByText('No Entries Found')).not.toBeInTheDocument();
});

it('rewinds the shared page param instead of leaving it out of range', async () => {
renderRecord(traceRow, 'Replaced', '/?page=3');

await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent(''));
expect(screen.getByTestId('search').textContent).toBe('');
});

it('explains when nothing was replaced', () => {
renderRecord({ biography: 'Nothing sensitive here.', biography_replaced: '' });

expect(screen.getByText('No entities were replaced in this record.')).toBeInTheDocument();
expect(screen.getByText('No output was produced for this record.')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flex, Stack, Text } from '@nvidia/foundations-react-core';
import { HighlightedText } from '@studio/components/AnonymizerRecordView/HighlightedText';
import { RecordSection } from '@studio/components/AnonymizerRecordView/RecordSection';
import { ReplacementMapTable } from '@studio/components/AnonymizerRecordView/ReplacementMapTable';
import type { AnonymizerRecord } from '@studio/components/AnonymizerRecordView/types';
import { memo, type FC } from 'react';

interface AnonymizerRecordViewProps {
readonly record: AnonymizerRecord;
readonly outputHeading: string;
}

export const AnonymizerRecordView: FC<AnonymizerRecordViewProps> = memo(
({ record, outputHeading }) => (
<Stack gap="density-2xl">
<Flex align="start" gap="density-2xl">
<RecordSection className="flex-1 min-w-0" heading="Original">
<HighlightedText
emptyMessage="This record has no text."
segments={record.originalSegments}
/>
</RecordSection>
<RecordSection className="flex-1 min-w-0" heading={outputHeading}>
<HighlightedText
emptyMessage="No output was produced for this record."
segments={record.replacedSegments}
/>
</RecordSection>
</Flex>

<RecordSection heading="Replacement Map">
{record.replacements.length ? (
<ReplacementMapTable replacements={record.replacements} />
) : (
<Text color="secondary" kind="body/regular/md">
No entities were replaced in this record.
</Text>
)}
</RecordSection>
</Stack>
)
);

AnonymizerRecordView.displayName = 'AnonymizerRecordView';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Badge, Text } from '@nvidia/foundations-react-core';
import type { TextSegment } from '@studio/components/AnonymizerRecordView/types';
import { entityTagColor } from '@studio/routes/AnonymizerBuilderRoute/constants';
import { memo, type FC } from 'react';

interface HighlightedTextProps {
readonly segments: readonly TextSegment[];
readonly emptyMessage: string;
}

export const HighlightedText: FC<HighlightedTextProps> = memo(({ segments, emptyMessage }) =>
segments.length ? (
<Text className="whitespace-pre-wrap break-words" kind="body/regular/md">
{segments.map((segment, index) => {
if (!segment.label) return <span key={index}>{segment.text}</span>;
const color = entityTagColor(segment.label);
return (
<span className="inline-flex items-center" key={index}>
<Badge color={color} kind="solid">
{segment.text}
</Badge>
<Badge color={color} kind="outline">
{segment.label}
</Badge>
</span>
);
})}
</Text>
) : (
<Text color="secondary" kind="body/regular/md">
{emptyMessage}
</Text>
)
);

HighlightedText.displayName = 'HighlightedText';
Comment thread
marcusds marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Stack, Text } from '@nvidia/foundations-react-core';
import type { FC, ReactNode } from 'react';

interface RecordSectionProps {
readonly heading: string;
readonly className?: string;
readonly children: ReactNode;
}

/** Shared by the record view and its skeleton so the two stay aligned. */
export const RecordSection: FC<RecordSectionProps> = ({ heading, className, children }) => (
<Stack className={className} gap="density-md">
<Text color="secondary" kind="label/regular/md">
{heading}
</Text>
{children}
</Stack>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView';
import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState';
import { Badge, Text } from '@nvidia/foundations-react-core';
import type { EntityReplacement } from '@studio/components/AnonymizerRecordView/types';
import { entityTagColor } from '@studio/routes/AnonymizerBuilderRoute/constants';
import { ArrowRight } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, type ComponentProps, type FC } from 'react';

const REPLACEMENTS_PAGE_SIZE = 20;
const ARROW_COLUMN_SIZE = 48;

interface ReplacementMapTableProps {
readonly replacements: readonly EntityReplacement[];
}

export const ReplacementMapTable: FC<ReplacementMapTableProps> = memo(({ replacements }) => {
const dataViewState = useStudioDataViewState({ defaultPageSize: REPLACEMENTS_PAGE_SIZE });

const { pageIndex: requestedPage, pageSize } = dataViewState.pagination.state;
// `page` is shared, so it outlives a record pager move to a map with fewer rows.
const lastPageIndex = Math.max(Math.ceil(replacements.length / pageSize) - 1, 0);
const pageIndex = Math.min(requestedPage, lastPageIndex);

const setPagination = dataViewState.pagination.set;
useEffect(() => {
if (requestedPage > lastPageIndex) {
setPagination((prev) => ({ ...prev, pageIndex: lastPageIndex }));
}
}, [requestedPage, lastPageIndex, setPagination]);

const pageRows = useMemo(
() => replacements.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
[replacements, pageIndex, pageSize]
);

const makeColumns = useCallback<
ComponentProps<typeof StudioDataView<EntityReplacement>>['makeColumns']
>(
(col) => [
col.display({
Comment thread
marcusds marked this conversation as resolved.
Outdated
id: 'label',
header: 'Label',
cell: ({ row }) => (
<Badge color={entityTagColor(row.original.label)} kind="outline">
{row.original.label}
</Badge>
),
}),
col.display({
id: 'original',
header: 'Original',
cell: ({ row }) => <Text kind="body/regular/sm">{row.original.original}</Text>,
}),
col.display({
id: 'arrow',
header: '',
size: ARROW_COLUMN_SIZE,
cell: () => <ArrowRight aria-hidden size={14} />,
}),
col.display({
id: 'synthetic',
header: 'Replacement',
cell: ({ row }) => <Text kind="body/regular/sm">{row.original.synthetic}</Text>,
}),
],
[]
);

return (
<StudioDataView<EntityReplacement>
dataViewState={dataViewState}
makeColumns={makeColumns}
maxTwoLines={false}
attributes={{
DataViewRoot: { data: pageRows, totalCount: replacements.length },
DataViewPagination: { showWhileEmpty: false, showWhileLessThanPageSize: false },
}}
/>
);
});

ReplacementMapTable.displayName = 'ReplacementMapTable';
Loading
Loading