diff --git a/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx b/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx index 05569fd652c49f..8f3e6a8183ec8e 100644 --- a/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx +++ b/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx @@ -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}) { + const tagFilters = useTagFilters(); + const onTagClick = tagFilters?.onToggleTagFilter; + const activeTagFilters = tagFilters?.activeTagFilters; const [expanded, setExpanded] = useState(false); const [overflowCount, setOverflowCount] = useState(0); const containerRef = useRef(null); @@ -64,11 +70,25 @@ export function CollapsibleBadgeRow({tags}: {tags: Record}) { position="relative" maxHeight={expanded ? undefined : `${ONE_ROW_HEIGHT}px`} > - {entries.map(([key, value]) => ( - - {key}={value} - - ))} + {entries.map(([key, value]) => + onTagClick ? ( + { + e.stopPropagation(); + onTagClick(key, value); + }} + > + {key}={value} + + ) : ( + + {key}={value} + + ) + )} {overflowCount > 0 && !expanded && ( }) { ); } + +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}; + } +`; diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx index ddd1f4d387c690..ea0c1e5ef6dacb 100644 --- a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx @@ -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, @@ -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, diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx index c85240971e4093..033a5cd028cd5c 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.snapshots.tsx @@ -70,6 +70,7 @@ describe('SnapshotSidebarContent', () => { statusCounts={statusCounts} activeStatuses={new Set()} onToggleStatus={noop} + availableTags={new Map()} /> ), @@ -89,6 +90,7 @@ describe('SnapshotSidebarContent', () => { statusCounts={statusCounts} activeStatuses={new Set()} onToggleStatus={noop} + availableTags={new Map()} /> ), @@ -107,6 +109,7 @@ describe('SnapshotSidebarContent', () => { statusCounts={statusCounts} activeStatuses={new Set([DiffStatus.UNCHANGED])} onToggleStatus={noop} + availableTags={new Map()} /> ), @@ -125,6 +128,7 @@ describe('SnapshotSidebarContent', () => { statusCounts={statusCounts} activeStatuses={new Set([DiffStatus.CHANGED, DiffStatus.UNCHANGED])} onToggleStatus={noop} + availableTags={new Map()} /> ), diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx index e381702ab3a59d..e30dcdec6209c6 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx @@ -39,6 +39,7 @@ function renderSidebar(sections: SidebarSection[]) { statusCounts={statusCounts} activeStatuses={new Set()} onToggleStatus={noop} + availableTags={new Map()} /> ); } diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx index 7fbd5de4cc01b4..3470ca979dce7d 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.tsx @@ -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 { @@ -66,6 +68,7 @@ const SECTION_HEADER_HEIGHT = 28; interface SnapshotSidebarContentProps { activeStatuses: Set; + availableTags: Map>; onSearchChange: (query: string) => void; onSelectItem: (itemKey: string) => void; onToggleStatus: (status: DiffStatus) => void; @@ -84,6 +87,7 @@ export const SnapshotSidebarContent = memo(function SnapshotSidebarContent({ statusCounts, activeStatuses, onToggleStatus, + availableTags, }: SnapshotSidebarContentProps) { const hasActiveFilter = activeStatuses.size > 0; const isStatusActive = (status: DiffStatus) => @@ -200,6 +204,7 @@ export const SnapshotSidebarContent = memo(function SnapshotSidebarContent({ )} + {availableTags.size > 0 && } {hasGroups ? (
>; +}) { + 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 ( + e.stopPropagation()}> + + + + {t('Tags')} + + + + + {sortedKeys.map(tagKey => { + const values = availableTags.get(tagKey)!; + const activeValue = activeTagFilters[tagKey]; + return ( + + + {tagKey} + + + {[...values.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([value, count]) => { + const isActive = activeValue === value; + const isDisabled = count === 0 && !isActive; + return ( + onToggleTagFilter(tagKey, value)} + > + + {value} + + + {count} + + + ); + })} + + + ); + })} + + + + {hasActiveFilter && ( + + {Object.entries(activeTagFilters).map(([key, value]) => ( + onToggleTagFilter(key, value)} + > + + {key}={value} + + + + ))} + + )} + + ); +}); + function setTitleOnOverflow(e: React.PointerEvent) { const el = e.currentTarget; el.title = el.scrollWidth > el.clientWidth ? (el.textContent ?? '') : ''; @@ -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; diff --git a/static/app/views/preprod/snapshots/snapshots.tsx b/static/app/views/preprod/snapshots/snapshots.tsx index 3d1e321665c8a2..787e05888589a3 100644 --- a/static/app/views/preprod/snapshots/snapshots.tsx +++ b/static/app/views/preprod/snapshots/snapshots.tsx @@ -49,6 +49,8 @@ import { type SidebarSection, SnapshotSidebarContent, } from './sidebar/snapshotSidebarContent'; +import {TagFilterProvider} from './tagFilterContext'; +import {narrowItemByTags} from './tagFiltering'; function imageGroupKey(img: SnapshotImage): string { return img.group ?? img.image_file_name; @@ -78,6 +80,13 @@ function itemVariantCount(item: SidebarItem): number { : item.images.length; } +function itemImages(item: SidebarItem): SnapshotImage[] { + if (item.type === 'changed' || item.type === 'renamed') { + return item.pairs.map(p => p.head_image); + } + return item.images; +} + function snapshotKeyAt(item: SidebarItem, variantIdx: number): string | null { if (item.type === 'changed' || item.type === 'renamed') { return item.pairs[variantIdx]?.head_image.image_file_name ?? null; @@ -202,6 +211,8 @@ export default function SnapshotsPage() { .withOptions(pushHistory) ); const activeStatuses = useMemo(() => new Set(activeStatusList), [activeStatusList]); + const [activeTagFilters, setActiveTagFilters] = useState>({}); + const hasActiveTagFilter = Object.keys(activeTagFilters).length > 0; const availableStatuses = useMemo( () => @@ -331,6 +342,23 @@ export default function SnapshotsPage() { })); }, [data, comparisonType]); + const handleToggleTagFilter = useCallback((key: string, value: string) => { + setActiveTagFilters(prev => { + const result = {...prev}; + if (prev[key] === value) { + delete result[key]; + } else { + result[key] = value; + } + return result; + }); + }, []); + + const tagFilterContextValue = useMemo( + () => ({activeTagFilters, onToggleTagFilter: handleToggleTagFilter}), + [activeTagFilters, handleToggleTagFilter] + ); + // Pre-computed lowercase text per image/pair for fast substring search filtering const memberSearchKeys = useMemo( () => sidebarItems.map(buildMemberSearchKeys), @@ -356,11 +384,80 @@ export default function SnapshotsPage() { return result; }, [sidebarItems, memberSearchKeys, searchQuery]); + const availableTags = useMemo(() => { + const hasStatusFilter = activeStatuses.size > 0 && comparisonType === 'diff'; + const activeTagKeys = Object.keys(activeTagFilters); + const trimmedQuery = searchQuery.trim().toLowerCase(); + const tagMap = new Map>(); + + for (let i = 0; i < sidebarItems.length; i++) { + const item = sidebarItems[i]!; + const passesStatus = + !hasStatusFilter || activeStatuses.has(item.type as DiffStatus); + const images = itemImages(item); + const searchKeys = memberSearchKeys[i]!; + + for (let j = 0; j < images.length; j++) { + const img = images[j]!; + if (!img.tags) { + continue; + } + + const passesSearch = !trimmedQuery || searchKeys[j]!.includes(trimmedQuery); + + const passesTagFilters = activeTagKeys.every(filterKey => { + const filterValue = activeTagFilters[filterKey]; + if (filterValue === undefined) { + return true; + } + return img.tags?.[filterKey] === filterValue; + }); + + const counted = passesStatus && passesSearch && passesTagFilters; + + for (const [k, v] of Object.entries(img.tags)) { + let values = tagMap.get(k); + if (!values) { + values = new Map(); + tagMap.set(k, values); + } + if (counted) { + values.set(v, (values.get(v) ?? 0) + 1); + } else if (!values.has(v)) { + values.set(v, 0); + } + } + } + } + return tagMap; + }, [ + sidebarItems, + memberSearchKeys, + searchQuery, + activeStatuses, + comparisonType, + activeTagFilters, + ]); + + const tagFilteredItems = useMemo(() => { + if (!hasActiveTagFilter) { + return searchFilteredItems; + } + const result: SidebarItem[] = []; + for (const item of searchFilteredItems) { + const narrowed = narrowItemByTags(item, activeTagFilters); + if (narrowed) { + result.push(narrowed); + } + } + return result; + }, [searchFilteredItems, hasActiveTagFilter, activeTagFilters]); + const filteredItems = useMemo(() => { const hasStatusFilter = activeStatuses.size > 0 && comparisonType === 'diff'; const base = hasStatusFilter - ? searchFilteredItems.filter(item => activeStatuses.has(item.type as DiffStatus)) - : searchFilteredItems; + ? tagFilteredItems.filter(item => activeStatuses.has(item.type as DiffStatus)) + : tagFilteredItems; return [...base].sort((a, b) => { const typeOrder = (DIFF_TYPE_ORDER[a.type] ?? 99) - (DIFF_TYPE_ORDER[b.type] ?? 99); @@ -375,7 +472,7 @@ export default function SnapshotsPage() { } return a.name.localeCompare(b.name); }); - }, [searchFilteredItems, activeStatuses, sortBy, comparisonType]); + }, [tagFilteredItems, activeStatuses, sortBy, comparisonType]); const sidebarSections = useMemo(() => { function toGroup(item: SidebarItem) { @@ -430,13 +527,13 @@ export default function SnapshotsPage() { [DiffStatus.UNCHANGED]: 0, [DiffStatus.SKIPPED]: 0, }; - for (const item of searchFilteredItems) { + for (const item of tagFilteredItems) { if (item.type in counts) { counts[item.type as DiffStatus] += itemVariantCount(item); } } return counts; - }, [searchFilteredItems, comparisonType]); + }, [tagFilteredItems, comparisonType]); const listViewRef = useRef(null); const [visibleItemKey, setVisibleItemKey] = useState(null); @@ -667,69 +764,72 @@ export default function SnapshotsPage() { }; const snapshotContent = ( - - - - - - - - - + + + + + + + + + + + - + ); if (isPending) { @@ -827,6 +927,11 @@ function imageSearchKey(image: SnapshotImage): string { if (image.group) { parts.push(image.group); } + if (image.tags) { + for (const [k, v] of Object.entries(image.tags)) { + parts.push(`${k}=${v}`); + } + } return parts.join('\n').toLowerCase(); } diff --git a/static/app/views/preprod/snapshots/tagChip.tsx b/static/app/views/preprod/snapshots/tagChip.tsx new file mode 100644 index 00000000000000..a4c4e596334852 --- /dev/null +++ b/static/app/views/preprod/snapshots/tagChip.tsx @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +export const TagChip = styled('button')<{isActive: boolean}>` + display: inline-flex; + align-items: center; + gap: ${p => p.theme.space.xs}; + padding: 2px ${p => p.theme.space.md}; + border-radius: ${p => p.theme.radius.md}; + border: 1px solid + ${p => + p.isActive ? p.theme.tokens.border.accent.vibrant : p.theme.tokens.border.primary}; + background: ${p => + p.isActive ? p.theme.tokens.background.transparent.accent.muted : 'transparent'}; + cursor: pointer; + + &:hover { + background: ${p => p.theme.tokens.background.secondary}; + } + + &:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; + } +`; diff --git a/static/app/views/preprod/snapshots/tagFilterContext.tsx b/static/app/views/preprod/snapshots/tagFilterContext.tsx new file mode 100644 index 00000000000000..f56fdcfd763745 --- /dev/null +++ b/static/app/views/preprod/snapshots/tagFilterContext.tsx @@ -0,0 +1,14 @@ +import {createContext, useContext} from 'react'; + +interface TagFilterContextValue { + activeTagFilters: Record; + onToggleTagFilter: (key: string, value: string) => void; +} + +const TagFilterContext = createContext(null); + +export const TagFilterProvider = TagFilterContext.Provider; + +export function useTagFilters(): TagFilterContextValue | null { + return useContext(TagFilterContext); +} diff --git a/static/app/views/preprod/snapshots/tagFiltering.spec.ts b/static/app/views/preprod/snapshots/tagFiltering.spec.ts new file mode 100644 index 00000000000000..958e987079ceb7 --- /dev/null +++ b/static/app/views/preprod/snapshots/tagFiltering.spec.ts @@ -0,0 +1,162 @@ +import type { + SnapshotDiffPair, + SnapshotImage, +} from 'sentry/views/preprod/types/snapshotTypes'; + +import {imageMatchesTagFilters, narrowItemByTags} from './tagFiltering'; + +function makeImage( + overrides: Partial & {image_file_name: string} +): SnapshotImage { + return { + key: overrides.image_file_name, + display_name: null, + height: 100, + width: 100, + tags: null, + ...overrides, + }; +} + +function makePair(head: SnapshotImage, base?: Partial): SnapshotDiffPair { + return { + head_image: head, + base_image: makeImage({ + image_file_name: base?.image_file_name ?? `base_${head.image_file_name}`, + ...base, + }), + diff: 0.05, + diff_image_key: null, + }; +} + +describe('imageMatchesTagFilters', () => { + it('returns false when image has no tags', () => { + const img = makeImage({image_file_name: 'a.png', tags: null}); + expect(imageMatchesTagFilters(img, {os: 'iOS'})).toBe(false); + }); + + it('returns true when image tags match all filters', () => { + const img = makeImage({ + image_file_name: 'a.png', + tags: {os: 'iOS', theme: 'dark'}, + }); + expect(imageMatchesTagFilters(img, {os: 'iOS', theme: 'dark'})).toBe(true); + }); + + it('returns false when one filter key does not match', () => { + const img = makeImage({ + image_file_name: 'a.png', + tags: {os: 'iOS', theme: 'dark'}, + }); + expect(imageMatchesTagFilters(img, {os: 'iOS', theme: 'light'})).toBe(false); + }); + + it('returns false when filter key is absent from image tags', () => { + const img = makeImage({ + image_file_name: 'a.png', + tags: {os: 'iOS'}, + }); + expect(imageMatchesTagFilters(img, {theme: 'dark'})).toBe(false); + }); + + it('returns true when filters are empty (vacuously true)', () => { + const img = makeImage({ + image_file_name: 'a.png', + tags: {os: 'iOS'}, + }); + expect(imageMatchesTagFilters(img, {})).toBe(true); + }); +}); + +describe('narrowItemByTags', () => { + it('returns null when no images match', () => { + const item = { + type: 'added' as const, + key: 'added:group', + name: 'group', + displayName: 'group', + images: [ + makeImage({image_file_name: 'a.png', tags: {os: 'iOS'}}), + makeImage({image_file_name: 'b.png', tags: {os: 'iOS'}}), + ], + }; + expect(narrowItemByTags(item, {os: 'Android'})).toBeNull(); + }); + + it('returns the original item when all images match', () => { + const item = { + type: 'added' as const, + key: 'added:group', + name: 'group', + displayName: 'group', + images: [ + makeImage({image_file_name: 'a.png', tags: {os: 'iOS'}}), + makeImage({image_file_name: 'b.png', tags: {os: 'iOS'}}), + ], + }; + const result = narrowItemByTags(item, {os: 'iOS'}); + expect(result).toBe(item); + }); + + it('returns a narrowed item when some images match', () => { + const matching = makeImage({image_file_name: 'a.png', tags: {os: 'iOS'}}); + const item = { + type: 'added' as const, + key: 'added:group', + name: 'group', + displayName: 'group', + images: [matching, makeImage({image_file_name: 'b.png', tags: {os: 'Android'}})], + }; + const result = narrowItemByTags(item, {os: 'iOS'}); + expect(result).not.toBe(item); + expect(result).toEqual(expect.objectContaining({images: [matching]})); + }); + + it('filters pairs for changed items', () => { + const matchingHead = makeImage({image_file_name: 'a.png', tags: {os: 'iOS'}}); + const nonMatchingHead = makeImage({image_file_name: 'b.png', tags: {os: 'Android'}}); + const pair1 = makePair(matchingHead); + const pair2 = makePair(nonMatchingHead); + + const item = { + type: 'changed' as const, + key: 'changed:group', + name: 'group', + displayName: 'group', + pairs: [pair1, pair2], + }; + const result = narrowItemByTags(item, {os: 'iOS'}); + expect(result).toEqual(expect.objectContaining({pairs: [pair1]})); + }); + + it('returns null when all pairs are filtered out', () => { + const item = { + type: 'changed' as const, + key: 'changed:group', + name: 'group', + displayName: 'group', + pairs: [makePair(makeImage({image_file_name: 'a.png', tags: {os: 'iOS'}}))], + }; + expect(narrowItemByTags(item, {os: 'Android'})).toBeNull(); + }); + + it('excludes images with no tags when filters are active', () => { + const item = { + type: 'added' as const, + key: 'added:group', + name: 'group', + displayName: 'group', + images: [ + makeImage({image_file_name: 'a.png', tags: null}), + makeImage({image_file_name: 'b.png', tags: {os: 'iOS'}}), + ], + }; + const result = narrowItemByTags(item, {os: 'iOS'}); + expect(result).toEqual( + expect.objectContaining({ + images: [expect.objectContaining({image_file_name: 'b.png'})], + }) + ); + }); +}); diff --git a/static/app/views/preprod/snapshots/tagFiltering.ts b/static/app/views/preprod/snapshots/tagFiltering.ts new file mode 100644 index 00000000000000..27f036403fdf05 --- /dev/null +++ b/static/app/views/preprod/snapshots/tagFiltering.ts @@ -0,0 +1,35 @@ +import type {SidebarItem, SnapshotImage} from 'sentry/views/preprod/types/snapshotTypes'; + +export function imageMatchesTagFilters( + img: SnapshotImage, + filters: Record +): boolean { + if (!img.tags) { + return false; + } + return Object.entries(filters).every(([key, value]) => img.tags?.[key] === value); +} + +export function narrowItemByTags( + item: SidebarItem, + filters: Record +): SidebarItem | null { + if (item.type === 'changed' || item.type === 'renamed') { + const kept = item.pairs.filter(p => imageMatchesTagFilters(p.head_image, filters)); + if (kept.length === 0) { + return null; + } + if (kept.length === item.pairs.length) { + return item; + } + return {...item, pairs: kept}; + } + const kept = item.images.filter(img => imageMatchesTagFilters(img, filters)); + if (kept.length === 0) { + return null; + } + if (kept.length === item.images.length) { + return item; + } + return {...item, images: kept}; +}