Skip to content
Merged
45 changes: 40 additions & 5 deletions static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';

import {Badge} from '@sentry/scraps/badge';
import {Button} from '@sentry/scraps/button';
import {Flex} from '@sentry/scraps/layout';

import {t} from 'sentry/locale';
import {TagChip} from 'sentry/views/preprod/snapshots/tagChip';
import {useTagFilters} from 'sentry/views/preprod/snapshots/tagFilterContext';

const ONE_ROW_HEIGHT = 20;

export function CollapsibleBadgeRow({tags}: {tags: Record<string, string>}) {
const tagFilters = useTagFilters();
const onTagClick = tagFilters?.onToggleTagFilter;
const activeTagFilters = tagFilters?.activeTagFilters;
const [expanded, setExpanded] = useState(false);
const [overflowCount, setOverflowCount] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -64,11 +70,25 @@ export function CollapsibleBadgeRow({tags}: {tags: Record<string, string>}) {
position="relative"
maxHeight={expanded ? undefined : `${ONE_ROW_HEIGHT}px`}
>
{entries.map(([key, value]) => (
<Badge key={key} variant="muted">
{key}={value}
</Badge>
))}
{entries.map(([key, value]) =>
onTagClick ? (
<ClickableBadge
key={key}
type="button"
isActive={activeTagFilters?.[key] === value}
onClick={e => {
e.stopPropagation();
onTagClick(key, value);
}}
>
{key}={value}
</ClickableBadge>
) : (
<Badge key={key} variant="muted">
{key}={value}
</Badge>
)
)}
{overflowCount > 0 && !expanded && (
<Flex
ref={toggleRef}
Expand All @@ -95,3 +115,18 @@ export function CollapsibleBadgeRow({tags}: {tags: Record<string, string>}) {
</Flex>
);
}

const ClickableBadge = styled(TagChip)`
height: ${ONE_ROW_HEIGHT}px;
padding: 0 ${p => p.theme.space.md};
font-size: ${p => p.theme.font.size.xs};
color: ${p =>
p.isActive ? p.theme.tokens.content.accent : p.theme.tokens.content.secondary};
white-space: nowrap;
box-sizing: border-box;

&:hover {
border-color: ${p => p.theme.tokens.border.accent.vibrant};
color: ${p => p.theme.tokens.content.primary};
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export function SnapshotMainContent({
diffPercent: currentPair.diff,
copyData: currentPair,
copyUrl: buildSnapshotLink(image.image_file_name),

onCopyLink: () =>
trackAnalytics('preprod.snapshots.details.image_link_copied', {
organization,
Expand Down Expand Up @@ -321,6 +322,7 @@ export function SnapshotMainContent({
status: DiffStatus.RENAMED,
copyData: currentPair,
copyUrl: buildSnapshotLink(image.image_file_name),

onCopyLink: () =>
trackAnalytics('preprod.snapshots.details.image_link_copied', {
organization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('SnapshotSidebarContent', () => {
statusCounts={statusCounts}
activeStatuses={new Set()}
onToggleStatus={noop}
availableTags={new Map()}
/>
</Wrapper>
),
Expand All @@ -89,6 +90,7 @@ describe('SnapshotSidebarContent', () => {
statusCounts={statusCounts}
activeStatuses={new Set()}
onToggleStatus={noop}
availableTags={new Map()}
/>
</Wrapper>
),
Expand All @@ -107,6 +109,7 @@ describe('SnapshotSidebarContent', () => {
statusCounts={statusCounts}
activeStatuses={new Set([DiffStatus.UNCHANGED])}
onToggleStatus={noop}
availableTags={new Map()}
/>
</Wrapper>
),
Expand All @@ -125,6 +128,7 @@ describe('SnapshotSidebarContent', () => {
statusCounts={statusCounts}
activeStatuses={new Set([DiffStatus.CHANGED, DiffStatus.UNCHANGED])}
onToggleStatus={noop}
availableTags={new Map()}
/>
</Wrapper>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function renderSidebar(sections: SidebarSection[]) {
statusCounts={statusCounts}
activeStatuses={new Set()}
onToggleStatus={noop}
availableTags={new Map()}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {InputGroup} from '@sentry/scraps/input';
import {Flex, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import {IconSearch} from 'sentry/icons';
import {IconClose, IconSearch} from 'sentry/icons';
import {t} from 'sentry/locale';
import {TagChip} from 'sentry/views/preprod/snapshots/tagChip';
import {useTagFilters} from 'sentry/views/preprod/snapshots/tagFilterContext';
import {DiffStatus} from 'sentry/views/preprod/types/snapshotTypes';

interface SidebarGroup {
Expand Down Expand Up @@ -66,6 +68,7 @@ const SECTION_HEADER_HEIGHT = 28;

interface SnapshotSidebarContentProps {
activeStatuses: Set<DiffStatus>;
availableTags: Map<string, Map<string, number>>;
onSearchChange: (query: string) => void;
onSelectItem: (itemKey: string) => void;
onToggleStatus: (status: DiffStatus) => void;
Expand All @@ -84,6 +87,7 @@ export const SnapshotSidebarContent = memo(function SnapshotSidebarContent({
statusCounts,
activeStatuses,
onToggleStatus,
availableTags,
}: SnapshotSidebarContentProps) {
const hasActiveFilter = activeStatuses.size > 0;
const isStatusActive = (status: DiffStatus) =>
Expand Down Expand Up @@ -200,6 +204,7 @@ export const SnapshotSidebarContent = memo(function SnapshotSidebarContent({
</Flex>
)}
</Stack>
{availableTags.size > 0 && <TagFilterSection availableTags={availableTags} />}
<Stack ref={scrollRef} overflow="auto" flex="1" paddingRight="0">
{hasGroups ? (
<div
Expand Down Expand Up @@ -339,6 +344,89 @@ function StatusPill({
);
}

const TagFilterSection = memo(function TagFilterSection({
availableTags,
}: {
availableTags: Map<string, Map<string, number>>;
}) {
const tagFilters = useTagFilters();
const sortedKeys = useMemo(() => [...availableTags.keys()].sort(), [availableTags]);

if (!tagFilters) {
return null;
}
const {activeTagFilters, onToggleTagFilter} = tagFilters;
const hasActiveFilter = Object.keys(activeTagFilters).length > 0;

return (
<Stack borderBottom="primary" onClick={e => e.stopPropagation()}>
<TagDisclosure size="xs">
<Disclosure.Title>
<Text size="sm" bold>
{t('Tags')}
</Text>
</Disclosure.Title>
<Disclosure.Content>
<Stack gap="lg" paddingBottom="lg" style={{maxHeight: 200, overflowY: 'auto'}}>
{sortedKeys.map(tagKey => {
const values = availableTags.get(tagKey)!;
const activeValue = activeTagFilters[tagKey];
return (
<Stack key={tagKey} gap="xs">
<Text size="xs" variant="muted" bold>
{tagKey}
</Text>
<Flex gap="xs" wrap="wrap">
{[...values.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([value, count]) => {
const isActive = activeValue === value;
const isDisabled = count === 0 && !isActive;
return (
<TagChip
key={value}
type="button"
isActive={isActive}
disabled={isDisabled}
onClick={() => onToggleTagFilter(tagKey, value)}
>
<Text size="xs" variant={isActive ? 'accent' : 'muted'}>
{value}
</Text>
<Text size="xs" variant="muted">
{count}
</Text>
</TagChip>
);
})}
</Flex>
</Stack>
);
})}
</Stack>
</Disclosure.Content>
</TagDisclosure>
{hasActiveFilter && (
<Flex gap="xs" wrap="wrap" padding="lg" paddingTop="0">
{Object.entries(activeTagFilters).map(([key, value]) => (
<TagChip
isActive
key={`${key}:${value}`}
type="button"
onClick={() => onToggleTagFilter(key, value)}
>
<Text size="xs">
{key}={value}
</Text>
<IconClose size="xs" />
</TagChip>
))}
</Flex>
)}
</Stack>
);
});

function setTitleOnOverflow(e: React.PointerEvent<HTMLElement>) {
const el = e.currentTarget;
el.title = el.scrollWidth > el.clientWidth ? (el.textContent ?? '') : '';
Expand Down Expand Up @@ -428,6 +516,19 @@ const SectionDisclosure = styled(Disclosure)`
}
`;

const TagDisclosure = styled(Disclosure)`
width: 100%;

> :first-child {
padding-right: 0;
border-radius: 0;

> button {
border-radius: 0;
}
}
`;

const VirtualRowPositioner = styled('div')`
position: absolute;
top: 0;
Expand Down
Loading
Loading