diff --git a/src/sentry/api/endpoints/project_details.py b/src/sentry/api/endpoints/project_details.py
index f9d7e83fcb1993..9890dc693604c8 100644
--- a/src/sentry/api/endpoints/project_details.py
+++ b/src/sentry/api/endpoints/project_details.py
@@ -34,6 +34,7 @@
from sentry.grouping.enhancer.exceptions import InvalidEnhancerConfig
from sentry.grouping.fingerprinting import FingerprintingRules, InvalidFingerprintingConfig
from sentry.ingest.inbound_filters import FilterTypes
+from sentry.issues.highlights import HighlightContextField
from sentry.lang.native.sources import (
InvalidSourcesError,
parse_backfill_sources,
@@ -120,6 +121,8 @@ class ProjectMemberSerializer(serializers.Serializer):
"performanceIssueCreationRate",
"performanceIssueCreationThroughPlatform",
"performanceIssueSendToPlatform",
+ "highlightContext",
+ "highlightTags",
]
)
class ProjectAdminSerializer(ProjectMemberSerializer):
@@ -179,6 +182,8 @@ class ProjectAdminSerializer(ProjectMemberSerializer):
dataScrubberDefaults = serializers.BooleanField(required=False)
sensitiveFields = ListField(child=serializers.CharField(), required=False)
safeFields = ListField(child=serializers.CharField(), required=False)
+ highlightContext = HighlightContextField(required=False)
+ highlightTags = ListField(child=serializers.CharField(), required=False)
storeCrashReports = serializers.IntegerField(
min_value=-1, max_value=STORE_CRASH_REPORTS_MAX, required=False, allow_null=True
)
@@ -639,6 +644,13 @@ def put(self, request: Request, project) -> Response:
if result.get("safeFields") is not None:
if project.update_option("sentry:safe_fields", result["safeFields"]):
changed_proj_settings["sentry:safe_fields"] = result["safeFields"]
+ if features.has("organizations:event-tags-tree-ui", project.organization):
+ if result.get("highlightContext") is not None:
+ if project.update_option("sentry:highlight_context", result["highlightContext"]):
+ changed_proj_settings["sentry:highlight_context"] = result["highlightContext"]
+ if result.get("highlightTags") is not None:
+ if project.update_option("sentry:highlight_tags", result["highlightTags"]):
+ changed_proj_settings["sentry:highlight_tags"] = result["highlightTags"]
if result.get("storeCrashReports") is not None:
if project.get_option("sentry:store_crash_reports") != result["storeCrashReports"]:
changed_proj_settings["sentry:store_crash_reports"] = result["storeCrashReports"]
diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py
index 979f7be95364a7..5d760c63b9ddd9 100644
--- a/src/sentry/api/serializers/models/project.py
+++ b/src/sentry/api/serializers/models/project.py
@@ -24,6 +24,7 @@
from sentry.eventstore.models import DEFAULT_SUBJECT_TEMPLATE
from sentry.features.base import ProjectFeature
from sentry.ingest.inbound_filters import FilterTypes
+from sentry.issues.highlights import get_highlight_preset_for_project
from sentry.lang.native.sources import parse_sources, redact_source_secrets
from sentry.lang.native.utils import convert_crashreport_count
from sentry.models.environment import EnvironmentProject
@@ -906,6 +907,7 @@ def get_attrs(
"org": orgs[str(item.organization_id)],
"options": options_by_project[item.id],
"processing_issues": processing_issues_by_project.get(item.id, 0),
+ "highlight_preset": get_highlight_preset_for_project(item),
}
)
return attrs
@@ -945,6 +947,14 @@ def serialize(
"verifySSL": bool(attrs["options"].get("sentry:verify_ssl", False)),
"scrubIPAddresses": bool(attrs["options"].get("sentry:scrub_ip_address", False)),
"scrapeJavaScript": bool(attrs["options"].get("sentry:scrape_javascript", True)),
+ "highlightTags": attrs["options"].get(
+ "sentry:highlight_tags",
+ attrs["highlight_preset"].get("tags", []),
+ ),
+ "highlightContext": attrs["options"].get(
+ "sentry:highlight_context",
+ attrs["highlight_preset"].get("context", {}),
+ ),
"groupingConfig": self.get_value_with_default(attrs, "sentry:grouping_config"),
"groupingEnhancements": self.get_value_with_default(
attrs, "sentry:grouping_enhancements"
diff --git a/src/sentry/issues/highlights.py b/src/sentry/issues/highlights.py
new file mode 100644
index 00000000000000..afd3d67bab0b66
--- /dev/null
+++ b/src/sentry/issues/highlights.py
@@ -0,0 +1,72 @@
+from collections.abc import Mapping
+from typing import TypedDict
+
+from jsonschema import Draft7Validator
+from jsonschema.exceptions import ValidationError as SchemaValidationError
+from rest_framework import serializers
+from rest_framework.serializers import ValidationError
+
+from sentry.models.project import Project
+from sentry.utils.json import JSONData
+from sentry.utils.platform_categories import BACKEND, FRONTEND, MOBILE
+
+HIGHLIGHT_CONTEXT_SCHEMA: JSONData = {
+ "type": "object",
+ "patternProperties": {"^.*$": {"type": "array", "items": {"type": "string"}}},
+ "additionalProperties": False,
+}
+
+
+class HighlightContextField(serializers.Field):
+ def to_internal_value(self, data):
+ if data is None:
+ return
+
+ if data == "" or data == {} or data == []:
+ return {}
+
+ v = Draft7Validator(HIGHLIGHT_CONTEXT_SCHEMA)
+ try:
+ v.validate(data)
+ except SchemaValidationError as e:
+ raise ValidationError(e.message)
+
+ return data
+
+
+class HighlightPreset(TypedDict):
+ tags: list[str]
+ context: Mapping[str, list[str]]
+
+
+SENTRY_TAGS = ["handled", "level", "release", "environment"]
+
+BACKEND_HIGHLIGHTS: HighlightPreset = {
+ "tags": SENTRY_TAGS + ["url", "transaction", "status_code"],
+ "context": {"trace": ["trace_id"], "runtime": ["name", "version"]},
+}
+FRONTEND_HIGHLIGHTS: HighlightPreset = {
+ "tags": SENTRY_TAGS + ["url", "transaction", "browser", "replayId", "user"],
+ "context": {"browser": ["name"], "user": ["email"]},
+}
+MOBILE_HIGHLIGHTS: HighlightPreset = {
+ "tags": SENTRY_TAGS + ["mobile", "main_thread"],
+ "context": {"profile": ["profile_id"], "app": ["name"], "device": ["family"]},
+}
+
+FALLBACK_HIGLIGHTS: HighlightPreset = {
+ "tags": SENTRY_TAGS,
+ "context": {"user": ["email"], "trace": ["trace_id"]},
+}
+
+
+def get_highlight_preset_for_project(project: Project) -> HighlightPreset:
+ if not project.platform or project.platform == "other":
+ return FALLBACK_HIGLIGHTS
+ elif project.platform in FRONTEND:
+ return FRONTEND_HIGHLIGHTS
+ elif project.platform in BACKEND:
+ return BACKEND_HIGHLIGHTS
+ elif project.platform in MOBILE:
+ return MOBILE_HIGHLIGHTS
+ return FALLBACK_HIGLIGHTS
diff --git a/src/sentry/models/options/project_option.py b/src/sentry/models/options/project_option.py
index 33a6a506b81553..b36b4babea89ee 100644
--- a/src/sentry/models/options/project_option.py
+++ b/src/sentry/models/options/project_option.py
@@ -30,6 +30,8 @@
"sentry:builtin_symbol_sources",
"sentry:symbol_sources",
"sentry:sensitive_fields",
+ "sentry:highlight_tags",
+ "sentry:highlight_context",
"sentry:csp_ignored_sources_defaults",
"sentry:csp_ignored_sources",
"sentry:default_environment",
diff --git a/static/app/components/events/contextSummary/index.tsx b/static/app/components/events/contextSummary/index.tsx
index 086c1da10b7384..228f0b471e44b1 100644
--- a/static/app/components/events/contextSummary/index.tsx
+++ b/static/app/components/events/contextSummary/index.tsx
@@ -1,7 +1,5 @@
import styled from '@emotion/styled';
-import {EventDataSection} from 'sentry/components/events/eventDataSection';
-import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Event} from 'sentry/types/event';
@@ -53,7 +51,6 @@ type Props = {
};
function ContextSummary({event}: Props) {
- const hasNewTagsUI = useHasNewTagsUI();
if (objectIsEmpty(event.contexts)) {
return null;
}
@@ -117,19 +114,6 @@ function ContextSummary({event}: Props) {
return ;
});
- if (hasNewTagsUI) {
- // TODO(Leander): When a design is confirmed, move this to HighlightsDataSection
- return (
-
- {contexts}
-
- );
- }
-
return {contexts};
}
diff --git a/static/app/components/events/contexts/contextCard.tsx b/static/app/components/events/contexts/contextCard.tsx
index 64736552915128..19282464f05f31 100644
--- a/static/app/components/events/contexts/contextCard.tsx
+++ b/static/app/components/events/contexts/contextCard.tsx
@@ -1,5 +1,6 @@
import {Link} from 'react-router';
import styled from '@emotion/styled';
+import startCase from 'lodash/startCase';
import {
getContextMeta,
@@ -10,7 +11,7 @@ import {AnnotatedTextErrors} from 'sentry/components/events/meta/annotatedText/a
import Panel from 'sentry/components/panels/panel';
import {StructuredData} from 'sentry/components/structuredEventData';
import {space} from 'sentry/styles/space';
-import type {Group, Project} from 'sentry/types';
+import type {Group, KeyValueListDataItem, Project} from 'sentry/types';
import type {Event} from 'sentry/types/event';
import {defined, objectIsEmpty} from 'sentry/utils';
import useOrganization from 'sentry/utils/useOrganization';
@@ -24,7 +25,71 @@ interface ContextCardProps {
value?: Record;
}
-function ContextCard({alias, event, type, project, value = {}}: ContextCardProps) {
+interface ContextCardContentConfig {
+ disableErrors?: boolean;
+ includeAliasInSubject?: boolean;
+}
+
+export interface ContextCardContentProps {
+ item: KeyValueListDataItem;
+ meta: Record;
+ alias?: string;
+ config?: ContextCardContentConfig;
+}
+
+export function ContextCardContent({
+ item,
+ alias,
+ meta,
+ config,
+ ...props
+}: ContextCardContentProps) {
+ const {key: contextKey, subject, value: contextValue, action = {}} = item;
+ if (contextKey === 'type') {
+ return null;
+ }
+ const contextMeta = meta?.[contextKey];
+ const contextErrors = contextMeta?.['']?.err ?? [];
+ const hasErrors = contextErrors.length > 0 && !config?.disableErrors;
+
+ const dataComponent = (
+
+ );
+
+ const contextSubject =
+ config?.includeAliasInSubject && alias ? `${startCase(alias)}: ${subject}` : subject;
+
+ return (
+
+ {contextSubject}
+
+ {defined(action?.link) ? (
+ {dataComponent}
+ ) : (
+ dataComponent
+ )}
+
+
+
+
+
+ );
+}
+
+export default function ContextCard({
+ alias,
+ event,
+ type,
+ project,
+ value = {},
+}: ContextCardProps) {
const organization = useOrganization();
if (objectIsEmpty(value)) {
return null;
@@ -39,43 +104,9 @@ function ContextCard({alias, event, type, project, value = {}}: ContextCardProps
project,
});
- const content = contextItems.map(
- ({key, subject, value: contextValue, action = {}}, i) => {
- if (key === 'type') {
- return null;
- }
- const contextMeta = meta?.[key];
- const contextErrors = contextMeta?.['']?.err ?? [];
- const hasErrors = contextErrors.length > 0;
-
- const dataComponent = (
-
- );
-
- return (
-
- {subject}
-
- {defined(action?.link) ? (
- {dataComponent}
- ) : (
- dataComponent
- )}
-
-
-
-
-
- );
- }
- );
+ const content = contextItems.map((item, i) => (
+
+ ));
return (
@@ -127,10 +158,6 @@ const ContextSubject = styled('div')`
const ContextValue = styled(ContextSubject)<{hasErrors: boolean}>`
color: ${p => (p.hasErrors ? 'inherit' : p.theme.textColor)};
grid-column: span ${p => (p.hasErrors ? 1 : 2)};
- /* justify-content: space-between;
- display: inline-flex; */
`;
const ContextErrors = styled('div')``;
-
-export default ContextCard;
diff --git a/static/app/components/events/contexts/contextDataSection.tsx b/static/app/components/events/contexts/contextDataSection.tsx
index ebc240e45c5151..59072ced77def5 100644
--- a/static/app/components/events/contexts/contextDataSection.tsx
+++ b/static/app/components/events/contexts/contextDataSection.tsx
@@ -1,20 +1,38 @@
import {useRef} from 'react';
import styled from '@emotion/styled';
+import {getOrderedContextItems} from 'sentry/components/events/contexts';
+import ContextCard from 'sentry/components/events/contexts/contextCard';
import {CONTEXT_DOCS_LINK} from 'sentry/components/events/contextSummary/utils';
import {EventDataSection} from 'sentry/components/events/eventDataSection';
import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
import ExternalLink from 'sentry/components/links/externalLink';
import {t, tct} from 'sentry/locale';
+import type {Event, Group, Project} from 'sentry/types';
interface ContextDataSectionProps {
- cards: React.ReactNode[];
+ event: Event;
+ group?: Group;
+ project?: Project;
}
-function ContextDataSection({cards}: ContextDataSectionProps) {
+function ContextDataSection({event, group, project}: ContextDataSectionProps) {
const containerRef = useRef(null);
const columnCount = useIssueDetailsColumnCount(containerRef);
const columns: React.ReactNode[] = [];
+
+ const cards = getOrderedContextItems(event).map(([alias, contextValue]) => (
+
+ ));
+
const columnSize = Math.ceil(cards.length / columnCount);
for (let i = 0; i < cards.length; i += columnSize) {
columns.push({cards.slice(i, i + columnSize)});
diff --git a/static/app/components/events/contexts/index.tsx b/static/app/components/events/contexts/index.tsx
index 89cc1644e98c6f..7ecde84da77f99 100644
--- a/static/app/components/events/contexts/index.tsx
+++ b/static/app/components/events/contexts/index.tsx
@@ -1,7 +1,6 @@
import {Fragment, useCallback, useEffect} from 'react';
import * as Sentry from '@sentry/react';
-import ContextCard from 'sentry/components/events/contexts/contextCard';
import ContextDataSection from 'sentry/components/events/contexts/contextDataSection';
import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
import type {Group} from 'sentry/types';
@@ -16,6 +15,38 @@ type Props = {
group?: Group;
};
+type ContextValueType = any;
+
+export function getOrderedContextItems(event): [string, ContextValueType][] {
+ const {user, contexts} = event;
+
+ const {feedback, response, ...otherContexts} = contexts ?? {};
+ const orderedContext: [string, ContextValueType][] = [
+ ['response', response],
+ ['feedback', feedback],
+ ['user', user],
+ ...Object.entries(otherContexts),
+ ];
+ // For these context keys, use 'key' as 'type' rather than 'value.type'
+ const overrideTypes = new Set(['response', 'feedback', 'user']);
+ const items = orderedContext
+ .filter(([_k, v]) => {
+ const contextKeys = Object.keys(v ?? {});
+ const isInvalid =
+ // Empty context
+ contextKeys.length === 0 ||
+ // Empty aside from 'type' key
+ (contextKeys.length === 1 && contextKeys[0] === 'type');
+ return !isInvalid;
+ })
+ .map<[string, ContextValueType]>(([alias, ctx]) => [
+ alias,
+ {...ctx, type: overrideTypes.has(ctx.type) ? ctx : ctx?.type ?? alias},
+ ]);
+
+ return items;
+}
+
export function EventContexts({event, group}: Props) {
const hasNewTagsUI = useHasNewTagsUI();
const {projects} = useProjects();
@@ -39,37 +70,7 @@ export function EventContexts({event, group}: Props) {
}, [usingOtel, sdk]);
if (hasNewTagsUI) {
- const orderedContext: [string, any][] = [
- ['response', response],
- ['feedback', feedback],
- ['user', user],
- ...Object.entries(otherContexts),
- ];
- // For these context keys, use 'key' as 'type' rather than 'value.type'
- const overrideTypes = new Set(['response', 'feedback', 'user']);
- const cards = orderedContext
- .filter(([_k, v]) => {
- const contextKeys = Object.keys(v ?? {});
- const isInvalid =
- // Empty context
- contextKeys.length === 0 ||
- // Empty aside from 'type' key
- (contextKeys.length === 1 && contextKeys[0] === 'type');
- return !isInvalid;
- })
- .map(([k, v]) => (
-
- ));
-
- return ;
+ return ;
}
return (
diff --git a/static/app/components/events/eventTags/eventTagsTree.tsx b/static/app/components/events/eventTags/eventTagsTree.tsx
index 05ab8a45158ff9..dd84ef7ce34078 100644
--- a/static/app/components/events/eventTags/eventTagsTree.tsx
+++ b/static/app/components/events/eventTags/eventTagsTree.tsx
@@ -144,18 +144,18 @@ function TagTreeColumns({
// If it's the last entry, create a column with the remaining rows
if (index === tagTreeRowGroups.length - 1) {
columns.push(
-
+
{tagTreeRowGroups.slice(startIndex)}
-
+
);
return {startIndex, runningTotal, columns};
}
- // If we reach the goal column size, wrap rows in a TreeColumn.
+ // If we reach the goal column size, wrap rows in a TagColumn.
if (runningTotal >= columnRowGoal) {
columns.push(
-
+
{tagTreeRowGroups.slice(startIndex, index)}
-
+
);
runningTotal = 0;
startIndex = index;
@@ -175,25 +175,20 @@ function EventTagsTree(props: EventTagsTreeProps) {
const containerRef = useRef(null);
const columnCount = useIssueDetailsColumnCount(containerRef);
return (
-
-
-
-
-
+
+
+
);
}
-const TreeContainer = styled('div')`
+export const TagContainer = styled('div')<{columnCount: number}>`
margin-top: ${space(1.5)};
-`;
-
-const TreeGarden = styled('div')<{columnCount: number}>`
display: grid;
grid-template-columns: repeat(${p => p.columnCount}, 1fr);
align-items: start;
`;
-const TreeColumn = styled('div')`
+export const TagColumn = styled('div')`
display: grid;
grid-template-columns: minmax(auto, 175px) 1fr;
grid-column-gap: ${space(3)};
diff --git a/static/app/components/events/eventTags/eventTagsTreeRow.tsx b/static/app/components/events/eventTags/eventTagsTreeRow.tsx
index 28e1a193d3ab0b..e6c0fe36dce17d 100644
--- a/static/app/components/events/eventTags/eventTagsTreeRow.tsx
+++ b/static/app/components/events/eventTags/eventTagsTreeRow.tsx
@@ -18,11 +18,17 @@ import {generateQueryWithTag, isUrl} from 'sentry/utils';
import useOrganization from 'sentry/utils/useOrganization';
import useRouter from 'sentry/utils/useRouter';
+interface EventTagTreeRowConfig {
+ disableActions?: boolean;
+ disableRichValue?: boolean;
+}
+
export interface EventTagsTreeRowProps {
content: TagTreeContent;
event: Event;
projectSlug: string;
tagKey: string;
+ config?: EventTagTreeRowConfig;
isLast?: boolean;
spacerCount?: number;
}
@@ -34,12 +40,14 @@ export default function EventTagsTreeRow({
projectSlug,
spacerCount = 0,
isLast = false,
+ config = {},
+ ...props
}: EventTagsTreeRowProps) {
const organization = useOrganization();
const originalTag = content.originalTag;
const tagMeta = content.meta?.value?.[''];
const tagErrors = tagMeta?.err ?? [];
- const hasTagErrors = tagErrors.length > 0;
+ const hasTagErrors = tagErrors.length > 0 && !config?.disableActions;
if (!originalTag) {
return (
@@ -57,8 +65,31 @@ export default function EventTagsTreeRow({
);
}
+ const tagValue =
+ originalTag.key === 'release' && !config?.disableRichValue ? (
+
+
+
+ ) : (
+
+ );
+
+ const tagActions = hasTagErrors ? (
+
+
+
+ ) : (
+
+ );
+
return (
-
+
{spacerCount > 0 && (
@@ -72,28 +103,8 @@ export default function EventTagsTreeRow({
-
- {originalTag.key === 'release' ? (
-
-
-
- ) : (
-
- )}
-
- {hasTagErrors ? (
-
-
-
- ) : (
-
- )}
+ {tagValue}
+ {!config?.disableActions && tagActions}
);
@@ -205,13 +216,14 @@ function EventTagsTreeRowDropdown({
);
}
-const TreeRow = styled('div')<{hasErrors: boolean}>`
+export const TreeRow = styled('div')<{hasErrors: boolean}>`
border-radius: ${space(0.5)};
padding-left: ${space(1)};
position: relative;
display: grid;
align-items: center;
grid-column: span 2;
+ column-gap: ${space(1.5)};
grid-template-columns: subgrid;
:nth-child(odd) {
background-color: ${p =>
@@ -270,17 +282,18 @@ const TreeValueTrunk = styled('div')`
grid-column-gap: ${space(0.5)};
`;
-const TreeValue = styled('div')`
+export const TreeValue = styled('div')<{hasErrors?: boolean}>`
padding: ${space(0.25)} 0;
align-self: start;
font-family: ${p => p.theme.text.familyMono};
font-size: ${p => p.theme.fontSizeSmall};
word-break: break-word;
grid-column: span 1;
+ color: ${p => (p.hasErrors ? 'inherit' : p.theme.textColor)};
`;
-const TreeKey = styled(TreeValue)<{hasErrors: boolean}>`
- color: ${p => (p.hasErrors ? 'inherit' : p.theme.gray300)};
+export const TreeKey = styled(TreeValue)<{hasErrors?: boolean}>`
+ color: ${p => (p.hasErrors ? 'inherit' : p.theme.subText)};
`;
/**
diff --git a/static/app/components/events/eventTags/util.tsx b/static/app/components/events/eventTags/util.tsx
index 11169a8d2797e1..0b0d2462ab3eee 100644
--- a/static/app/components/events/eventTags/util.tsx
+++ b/static/app/components/events/eventTags/util.tsx
@@ -172,8 +172,9 @@ const ISSUE_DETAILS_COLUMN_BREAKPOINTS = [
* rendered in the page contents, modals, and asides, we can't rely on window breakpoint to
* accurately describe the available space.
*/
-export function useIssueDetailsColumnCount(containerRef: RefObject): number {
- const {width} = useDimensions({elementRef: containerRef});
+export function useIssueDetailsColumnCount(elementRef: RefObject): number {
+ const {width} = useDimensions({elementRef});
+
const breakPoint = ISSUE_DETAILS_COLUMN_BREAKPOINTS.find(
({minWidth}) => width >= minWidth
);
diff --git a/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx b/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
index b376151aaa3d78..0f0f274f06f4ab 100644
--- a/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
+++ b/static/app/components/events/eventTagsAndScreenshot/index.spec.tsx
@@ -514,7 +514,7 @@ describe('EventTagsAndScreenshot', function () {
});
});
- describe("renders changes for 'event-tags-new-ui' flag", function () {
+ describe("renders changes for 'event-tags-tree-ui' flag", function () {
const featuredOrganization = OrganizationFixture({
features: ['event-attachments', 'event-tags-tree-ui'],
});
@@ -595,7 +595,7 @@ describe('EventTagsAndScreenshot', function () {
assertFlagAndQueryParamWork();
});
- it("allows filtering with 'event-tags-new-ui' flag", async function () {
+ it("allows filtering with 'event-tags-tree-ui' flag", async function () {
MockApiClient.addMockResponse({
url: `/projects/${featuredOrganization.slug}/${project.slug}/events/${event.id}/attachments/`,
body: [],
@@ -636,7 +636,7 @@ describe('EventTagsAndScreenshot', function () {
expect(rows).toHaveLength(allTags.length);
});
- it("promotes custom tags with 'event-tags-new-ui' flag", async function () {
+ it("promotes custom tags with 'event-tags-tree-ui' flag", async function () {
MockApiClient.addMockResponse({
url: `/projects/${featuredOrganization.slug}/${project.slug}/events/${event.id}/attachments/`,
body: [],
diff --git a/static/app/components/events/highlights/editHighlightsModal.tsx b/static/app/components/events/highlights/editHighlightsModal.tsx
new file mode 100644
index 00000000000000..0ec2bf4a43e253
--- /dev/null
+++ b/static/app/components/events/highlights/editHighlightsModal.tsx
@@ -0,0 +1,467 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import type {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {getOrderedContextItems} from 'sentry/components/events/contexts';
+import {ContextCardContent} from 'sentry/components/events/contexts/contextCard';
+import {getContextMeta} from 'sentry/components/events/contexts/utils';
+import EventTagsTreeRow from 'sentry/components/events/eventTags/eventTagsTreeRow';
+import type {
+ HighlightContext,
+ HighlightTags,
+} from 'sentry/components/events/highlights/highlightsDataSection';
+import {
+ getHighlightContextItems,
+ getHighlightTagItems,
+} from 'sentry/components/events/highlights/util';
+import {IconAdd, IconSubtract} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Event, Project} from 'sentry/types';
+import {useMutation} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface EditHighlightsModalProps extends ModalRenderProps {
+ event: Event;
+ highlightContext: HighlightContext;
+ highlightTags: HighlightTags;
+ project: Project;
+}
+
+interface EditPreviewHighlightSectionProps {
+ event: Event;
+ highlightContext: HighlightContext;
+ highlightTags: HighlightTags;
+ onRemoveContextKey: (contextType: string, contextKey: string) => void;
+ onRemoveTag: (tagKey: string) => void;
+ project: Project;
+}
+
+function EditPreviewHighlightSection({
+ event,
+ project,
+ highlightContext,
+ highlightTags,
+ onRemoveContextKey,
+ onRemoveTag,
+}: EditPreviewHighlightSectionProps) {
+ const organization = useOrganization();
+ const previewColumnCount = 2;
+
+ const highlightContextDataItems = getHighlightContextItems({
+ event,
+ project,
+ organization,
+ highlightContext,
+ });
+ const highlightContextRows = highlightContextDataItems.reduce(
+ (rowList, [alias, items], i) => {
+ const meta = getContextMeta(event, alias);
+ const newRows = items.map((item, j) => (
+
+ }
+ size="xs"
+ onClick={() => onRemoveContextKey(alias, item.key)}
+ />
+
+
+ ));
+ return [...rowList, ...newRows];
+ },
+ []
+ );
+
+ const highlightTagItems = getHighlightTagItems({event, highlightTags});
+ const highlightTagRows = highlightTagItems.map((content, i) => (
+
+ }
+ size="xs"
+ onClick={() => onRemoveTag(content.originalTag.key)}
+ />
+
+
+ ));
+
+ const rows = [...highlightTagRows, ...highlightContextRows];
+ const columns: React.ReactNode[] = [];
+ const columnSize = Math.ceil(rows.length / previewColumnCount);
+ for (let i = 0; i < rows.length; i += columnSize) {
+ columns.push(
+
+ {rows.slice(i, i + columnSize)}
+
+ );
+ }
+ return (
+
+ {columns}
+
+ );
+}
+
+interface EditTagHighlightSectionProps {
+ columnCount: number;
+ event: EditHighlightsModalProps['event'];
+ highlightTags: HighlightTags;
+ onAddTag: (tagKey: string) => void;
+}
+
+function EditTagHighlightSection({
+ columnCount,
+ event,
+ highlightTags,
+ onAddTag,
+}: EditTagHighlightSectionProps) {
+ const tagData = event.tags.map(tag => tag.key);
+ const tagColumnSize = Math.ceil(tagData.length / columnCount);
+ const tagColumns: React.ReactNode[] = [];
+ const highlightTagsSet = new Set(highlightTags);
+ for (let i = 0; i < tagData.length; i += tagColumnSize) {
+ tagColumns.push(
+
+ {tagData.slice(i, i + tagColumnSize).map((tagKey, j) => {
+ const isDisabled = highlightTagsSet.has(tagKey);
+ return (
+
+ }
+ size="xs"
+ onClick={() => onAddTag(tagKey)}
+ disabled={isDisabled}
+ title={isDisabled && t('Already highlighted')}
+ tooltipProps={{delay: 500}}
+ />
+
+ {tagKey}
+
+
+ );
+ })}
+
+ );
+ }
+ return (
+
+ {t('Tags')}
+
+ {tagColumns}
+
+
+ );
+}
+
+interface EditContextHighlightSectionProps {
+ columnCount: number;
+ event: EditHighlightsModalProps['event'];
+ highlightContext: HighlightContext;
+ onAddContextKey: (contextType: string, contextKey: string) => void;
+}
+
+function EditContextHighlightSection({
+ columnCount,
+ event,
+ highlightContext,
+ onAddContextKey,
+}: EditContextHighlightSectionProps) {
+ const ctxDisableMap: Record> = Object.entries(
+ highlightContext
+ ).reduce(
+ (disableMap, [contextType, contextKeys]) => ({
+ ...disableMap,
+ [contextType]: new Set(contextKeys ?? []),
+ }),
+ {}
+ );
+ const ctxData: Record = getOrderedContextItems(event).reduce(
+ (acc, [alias, context]) => {
+ acc[alias] = Object.keys(context).filter(k => k !== 'type');
+ return acc;
+ },
+ {}
+ );
+ const ctxItems = Object.entries(ctxData);
+ const ctxColumnSize = Math.ceil(ctxItems.length / columnCount);
+ const contextColumns: React.ReactNode[] = [];
+ for (let i = 0; i < ctxItems.length; i += ctxColumnSize) {
+ contextColumns.push(
+
+ {ctxItems.slice(i, i + ctxColumnSize).map(([contextType, contextKeys], j) => (
+
+ {contextType}
+ {contextKeys.map((contextKey, k) => {
+ const isDisabled = ctxDisableMap[contextType]?.has(contextKey) ?? false;
+ return (
+
+ }
+ size="xs"
+ onClick={() => onAddContextKey(contextType, contextKey)}
+ disabled={isDisabled}
+ title={isDisabled && t('Already highlighted')}
+ tooltipProps={{delay: 500}}
+ />
+
+ {contextKey}
+
+
+ );
+ })}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {t('Context')}
+
+ {contextColumns}
+
+
+ );
+}
+
+export default function EditHighlightsModal({
+ Header,
+ Body,
+ Footer,
+ event,
+ highlightContext: prevHighlightContext,
+ highlightTags: prevHighlightTags,
+ project,
+ closeModal,
+}: EditHighlightsModalProps) {
+ const [highlightContext, setHighlightContext] =
+ useState(prevHighlightContext);
+ const [highlightTags, setHighlightTags] = useState(prevHighlightTags);
+
+ const organization = useOrganization();
+ const api = useApi();
+
+ const {mutate: saveHighlights, isLoading} = useMutation({
+ mutationFn: () => {
+ return api.requestPromise(`/projects/${organization.slug}/${project.slug}/`, {
+ method: 'PUT',
+ data: {
+ highlightContext,
+ highlightTags,
+ },
+ });
+ },
+ onSuccess: (_updatedProject: Project) => {
+ addSuccessMessage(
+ tct(`Successfully updated highlights for '[projectName]' project`, {
+ projectName: project.name,
+ })
+ );
+ closeModal();
+ },
+ onError: _error => {
+ addErrorMessage(
+ tct(`Failed to update highlights for '[projectName]' project`, {
+ projectName: project.name,
+ })
+ );
+ },
+ });
+
+ const columnCount = 3;
+ return (
+
+
+ {t('Edit Event Highlights')}
+
+
+
+ setHighlightTags(highlightTags.filter(tag => tag !== tagKey))
+ }
+ onRemoveContextKey={(contextType, contextKey) =>
+ setHighlightContext({
+ ...highlightContext,
+ [contextType]: (highlightContext[contextType] ?? []).filter(
+ key => key !== contextKey
+ ),
+ })
+ }
+ project={project}
+ />
+ setHighlightTags([...highlightTags, tagKey])}
+ />
+
+ setHighlightContext({
+ ...highlightContext,
+ [contextType]: [...(highlightContext[contextType] ?? []), contextKey],
+ })
+ }
+ />
+
+
+
+ );
+}
+
+const Title = styled('h3')`
+ font-size: ${p => p.theme.fontSizeLarge};
+`;
+
+const Subtitle = styled('h4')`
+ font-size: ${p => p.theme.fontSizeMedium};
+ border-bottom: 1px solid ${p => p.theme.border};
+ margin-bottom: ${space(1.5)};
+ padding-bottom: ${space(0.5)};
+`;
+
+const EditHighlightPreview = styled('div')<{columnCount: number}>`
+ border: 1px dashed ${p => p.theme.border};
+ border-radius: 4px;
+ padding: ${space(2)};
+ display: grid;
+ grid-template-columns: repeat(${p => p.columnCount}, minmax(0, 1fr));
+ align-items: start;
+ margin: 0 -${space(1.5)};
+ font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const EditHighlightSection = styled('div')`
+ margin-top: 25px;
+`;
+
+const EditHighlightSectionContent = styled('div')<{columnCount: number}>`
+ display: grid;
+ grid-template-columns: repeat(${p => p.columnCount}, minmax(0, 1fr));
+`;
+
+const EditHighlightColumn = styled('div')`
+ grid-column: span 1;
+ &:not(:first-child) {
+ border-left: 1px solid ${p => p.theme.innerBorder};
+ padding-left: ${space(2)};
+ margin-left: -1px;
+ }
+ &:not(:last-child) {
+ border-right: 1px solid ${p => p.theme.innerBorder};
+ padding-right: ${space(2)};
+ }
+`;
+
+const EditPreviewColumn = styled(EditHighlightColumn)`
+ display: grid;
+ grid-template-columns: 22px auto 1fr;
+ column-gap: 0;
+ button {
+ margin-right: ${space(0.25)};
+ }
+`;
+
+const EditPreviewContextItem = styled(ContextCardContent)`
+ font-size: ${p => p.theme.fontSizeSmall};
+ grid-column: span 2;
+ .ctx-row-value {
+ grid-column: span 1;
+ }
+ &:nth-child(4n-2) {
+ background-color: ${p => p.theme.backgroundSecondary};
+ }
+`;
+
+const EditPreviewTagItem = styled(EventTagsTreeRow)`
+ &:nth-child(4n-2) {
+ background-color: ${p => p.theme.backgroundSecondary};
+ }
+`;
+
+const EditTagContainer = styled('div')`
+ display: grid;
+ grid-template-columns: 26px 1fr;
+ font-size: ${p => p.theme.fontSizeSmall};
+ align-items: center;
+`;
+
+const EditContextContainer = styled(EditTagContainer)`
+ margin-bottom: ${space(1)};
+`;
+
+const EditButton = styled(Button)`
+ grid-column: span 1;
+ color: ${p => (p.disabled ? p.theme.disabledBorder : p.theme.subText)};
+ border-color: ${p => (p.disabled ? p.theme.disabledBorder : p.theme.border)};
+ width: 18px;
+ height: 18px;
+ min-height: 18px;
+ border-radius: 4px;
+ margin: ${space(0.25)} 0;
+ align-self: start;
+ svg {
+ height: 10px;
+ width: 10px;
+ }
+ &:hover {
+ color: ${p => (p.disabled ? p.theme.disabledBorder : p.theme.subText)};
+ }
+`;
+
+const HighlightKey = styled('p')<{disabled?: boolean}>`
+ grid-column: span 1;
+ color: ${p => (p.disabled ? p.theme.disabledBorder : p.theme.subText)};
+ font-family: ${p => p.theme.text.familyMono};
+ margin-bottom: 0;
+ word-wrap: break-word;
+ word-break: break-all;
+ display: inline-block;
+`;
+
+const ContextType = styled('p')`
+ grid-column: span 2;
+ font-weight: bold;
+ text-transform: capitalize;
+ margin-bottom: ${space(0.25)};
+`;
diff --git a/static/app/components/events/highlights/highlightsDataSection.tsx b/static/app/components/events/highlights/highlightsDataSection.tsx
index 6b325a2be990b0..d57891468df2bd 100644
--- a/static/app/components/events/highlights/highlightsDataSection.tsx
+++ b/static/app/components/events/highlights/highlightsDataSection.tsx
@@ -1,18 +1,169 @@
-import ContextSummary from 'sentry/components/events/contextSummary';
-import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
+import {useRef} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {ContextCardContent} from 'sentry/components/events/contexts/contextCard';
+import {getContextMeta} from 'sentry/components/events/contexts/utils';
+import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import {TagColumn, TagContainer} from 'sentry/components/events/eventTags/eventTagsTree';
+import EventTagsTreeRow from 'sentry/components/events/eventTags/eventTagsTreeRow';
+import {
+ useHasNewTagsUI,
+ useIssueDetailsColumnCount,
+} from 'sentry/components/events/eventTags/util';
+import EditHighlightsModal from 'sentry/components/events/highlights/editHighlightsModal';
+import {
+ getHighlightContextItems,
+ getHighlightTagItems,
+} from 'sentry/components/events/highlights/util';
+import {IconEdit} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
import type {Event, Group, Project} from 'sentry/types';
+import {useDetailedProject} from 'sentry/utils/useDetailedProject';
+import useOrganization from 'sentry/utils/useOrganization';
interface HighlightsSectionProps {
event: Event;
group: Group;
project: Project;
+ viewAllRef?: React.RefObject;
}
+export type HighlightTags = Required['highlightTags'];
+export type HighlightContext = Required['highlightContext'];
-export default function HighlightsDataSection({event}: HighlightsSectionProps) {
+export default function HighlightsDataSection({
+ event,
+ project,
+ viewAllRef,
+}: HighlightsSectionProps) {
const hasNewTagsUI = useHasNewTagsUI();
+ const organization = useOrganization();
+ const containerRef = useRef(null);
+ const columnCount = useIssueDetailsColumnCount(containerRef);
+ const {
+ isLoading,
+ data: detailedProject,
+ refetch,
+ } = useDetailedProject({
+ orgSlug: organization.slug,
+ projectSlug: project.slug,
+ });
+
if (!hasNewTagsUI) {
return null;
}
- // TODO(Leander): When a design is confirmed, remove this usage of ContextSummary
- return ;
+
+ const highlightContext = detailedProject?.highlightContext ?? {};
+ const highlightTags = detailedProject?.highlightTags ?? [];
+ const viewAllButton = viewAllRef ? (
+
+ ) : null;
+
+ const highlightContextDataItems = getHighlightContextItems({
+ event,
+ project,
+ organization,
+ highlightContext,
+ });
+ const highlightContextRows = highlightContextDataItems.reduce(
+ (rowList, [alias, items], i) => {
+ const meta = getContextMeta(event, alias);
+ const newRows = items.map((item, j) => (
+
+ ));
+ return [...rowList, ...newRows];
+ },
+ []
+ );
+
+ const highlightTagItems = getHighlightTagItems({event, highlightTags});
+ const highlightTagRows = highlightTagItems.map((content, i) => (
+
+ ));
+
+ const rows = [...highlightTagRows, ...highlightContextRows];
+ const columns: React.ReactNode[] = [];
+ const columnSize = Math.ceil(rows.length / columnCount);
+ for (let i = 0; i < rows.length; i += columnSize) {
+ columns.push(
+
+ {rows.slice(i, i + columnSize)}
+
+ );
+ }
+
+ return (
+
+ {viewAllButton}
+ }
+ onClick={() =>
+ openModal(
+ deps => (
+
+ ),
+ {modalCss: highlightModalCss, onClose: refetch}
+ )
+ }
+ >
+ {t('Edit')}
+
+
+ }
+ >
+
+ {isLoading ? null : columns}
+
+
+ );
}
+
+const HighlightContainer = styled(TagContainer)<{columnCount: number}>`
+ margin-top: 0;
+ margin-bottom: ${space(2)};
+`;
+
+const HighlightColumn = styled(TagColumn)`
+ grid-column: span 1;
+`;
+
+const HighlightContextContent = styled(ContextCardContent)`
+ font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+export const highlightModalCss = css`
+ width: 850px;
+`;
diff --git a/static/app/components/events/highlights/util.tsx b/static/app/components/events/highlights/util.tsx
new file mode 100644
index 00000000000000..5661983264a4e7
--- /dev/null
+++ b/static/app/components/events/highlights/util.tsx
@@ -0,0 +1,88 @@
+import {getOrderedContextItems} from 'sentry/components/events/contexts';
+import {getFormattedContextData} from 'sentry/components/events/contexts/utils';
+import type {TagTreeContent} from 'sentry/components/events/eventTags/eventTagsTree';
+import type {
+ HighlightContext,
+ HighlightTags,
+} from 'sentry/components/events/highlights/highlightsDataSection';
+import type {
+ Event,
+ EventTag,
+ KeyValueListData,
+ Organization,
+ Project,
+} from 'sentry/types';
+
+export function getHighlightContextItems({
+ event,
+ highlightContext,
+ project,
+ organization,
+}: {
+ event: Event;
+ highlightContext: HighlightContext;
+ organization: Organization;
+ project: Project;
+}) {
+ const highlightContextSets: Record> = Object.entries(
+ highlightContext
+ ).reduce(
+ (hcSets, [contextType, contextKeys]) => ({
+ ...hcSets,
+ [contextType]: new Set(contextKeys),
+ }),
+ {}
+ );
+ const allContextDataMap: Record =
+ getOrderedContextItems(event).reduce((ctxMap, [alias, contextValue]) => {
+ ctxMap[alias] = {
+ contextType: contextValue.type,
+ data: getFormattedContextData({
+ event,
+ contextType: contextValue.type,
+ contextValue,
+ organization,
+ project,
+ }),
+ };
+ return ctxMap;
+ }, {});
+ // 2D Array of highlighted context data. We flatten it because
+ const highlightContextDataItems: [alias: string, KeyValueListData][] = Object.entries(
+ allContextDataMap
+ ).map(([alias, {contextType, data}]) => {
+ // Find the key set from highlight preferences
+ const highlightContextKeys =
+ highlightContextSets[alias] ?? highlightContextSets[contextType] ?? new Set([]);
+ // Filter to only items from that set
+ const highlightContextData: KeyValueListData = data.filter(
+ ({key, subject}) =>
+ // Need to do both since they differ
+ highlightContextKeys.has(key) || highlightContextKeys.has(subject)
+ );
+ return [alias, highlightContextData];
+ });
+
+ return highlightContextDataItems;
+}
+
+export function getHighlightTagItems({
+ event,
+ highlightTags,
+}: {
+ event: Event;
+ highlightTags: HighlightTags;
+}): Required[] {
+ const EMPTY_TAG_VALUE = '';
+ const tagMap: Record; tag: EventTag}> =
+ event.tags.reduce((tm, tag, i) => {
+ tm[tag.key] = {tag, meta: event._meta?.tags?.[i]};
+ return tm;
+ }, {});
+ return highlightTags.map(tagKey => ({
+ subtree: {},
+ meta: tagMap[tagKey]?.meta ?? {},
+ value: tagMap[tagKey]?.tag?.value ?? EMPTY_TAG_VALUE,
+ originalTag: tagMap[tagKey]?.tag ?? {key: tagKey, value: EMPTY_TAG_VALUE},
+ }));
+}
diff --git a/static/app/data/forms/projectGeneralSettings.tsx b/static/app/data/forms/projectGeneralSettings.tsx
index 140315b60b4099..90e7755fb5c1a3 100644
--- a/static/app/data/forms/projectGeneralSettings.tsx
+++ b/static/app/data/forms/projectGeneralSettings.tsx
@@ -2,7 +2,10 @@ import {createFilter} from 'react-select';
import styled from '@emotion/styled';
import {PlatformIcon} from 'platformicons';
+import {CONTEXT_DOCS_LINK} from 'sentry/components/events/contextSummary/utils';
+import {TAGS_DOCS_LINK} from 'sentry/components/events/eventTags/util';
import type {Field} from 'sentry/components/forms/types';
+import ExternalLink from 'sentry/components/links/externalLink';
import platforms from 'sentry/data/platforms';
import {t, tct, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
@@ -89,6 +92,56 @@ export const fields: Record = {
},
}),
},
+ highlightTags: {
+ name: 'highlightTags',
+ type: 'string',
+ multiline: true,
+ autosize: true,
+ rows: 1,
+ placeholder: t('handled, environment, release, my-tag'),
+ label: t('Highlighted Tags'),
+ help: tct(
+ '[link:Tags] to promote to the top of each issue page for quick debugging. Separate entries with a newline.',
+ {
+ link: ,
+ }
+ ),
+ getValue: val => extractMultilineFields(val),
+ setValue: val => convertMultilineFieldValue(val),
+ },
+ highlightContext: {
+ name: 'highlightContext',
+ type: 'textarea',
+ multiline: true,
+ autosize: true,
+ rows: 1,
+ placeholder: t('{"response": ["method", "status_code"], "browser": ["version"]}'),
+ label: t('Highlighted Context'),
+ help: tct(
+ 'Structured context keys to promote for quick debugging. Click [link:here] for documentation',
+ {
+ link: ,
+ }
+ ),
+ getValue: (val: string) => (val === '' ? {} : JSON.parse(val)),
+ setValue: (val: string) => {
+ const schema = JSON.stringify(val, null, 2);
+ if (schema === '{}') {
+ return '';
+ }
+ return schema;
+ },
+ validate: ({id, form}) => {
+ if (form.highlightContext) {
+ try {
+ JSON.parse(form.highlightContext);
+ } catch (e) {
+ return [[id, 'Invalid JSON']];
+ }
+ }
+ return [];
+ },
+ },
subjectPrefix: {
name: 'subjectPrefix',
diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx
index a2239ff4790c05..b724ecfc05886d 100644
--- a/static/app/types/project.tsx
+++ b/static/app/types/project.tsx
@@ -59,6 +59,8 @@ export type Project = {
builtinSymbolSources?: string[];
defaultEnvironment?: string;
hasUserReports?: boolean;
+ highlightContext?: Record;
+ highlightTags?: string[];
latestDeploys?: Record> | null;
latestRelease?: {version: string} | null;
options?: Record;
diff --git a/static/app/utils/useDetailedProject.tsx b/static/app/utils/useDetailedProject.tsx
new file mode 100644
index 00000000000000..3af40f26579394
--- /dev/null
+++ b/static/app/utils/useDetailedProject.tsx
@@ -0,0 +1,26 @@
+import type {Project} from 'sentry/types';
+import {
+ type ApiQueryKey,
+ useApiQuery,
+ type UseApiQueryOptions,
+} from 'sentry/utils/queryClient';
+
+interface DetailedProjectParameters {
+ orgSlug: string;
+ projectSlug: string;
+}
+
+export const makeDetailedProjectQueryKey = ({
+ orgSlug,
+ projectSlug,
+}: DetailedProjectParameters): ApiQueryKey => [`/projects/${orgSlug}/${projectSlug}/`];
+
+export function useDetailedProject(
+ params: DetailedProjectParameters,
+ options: Partial> = {}
+) {
+ return useApiQuery(makeDetailedProjectQueryKey(params), {
+ staleTime: Infinity,
+ ...options,
+ });
+}
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
index bba7feb457e0ec..bf9920c13b6512 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
@@ -497,7 +497,7 @@ describe('groupEventDetails', () => {
describe('changes to event tags ui', () => {
async function assertNewTagsView() {
expect(await screen.findByText('Event ID:')).toBeInTheDocument();
- const contextSummary = screen.getByTestId('highlighted-event-data');
+ const contextSummary = screen.getByTestId('event-highlights');
const contextSummaryContainer = within(contextSummary);
// 3 contexts in makeDefaultMockData.event.contexts, trace is ignored
expect(contextSummaryContainer.queryAllByTestId('context-item')).toHaveLength(3);
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
index addb8fc03f95b2..55b813c145befd 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, useRef} from 'react';
import styled from '@emotion/styled';
import {CommitRow} from 'sentry/components/commitRow';
@@ -84,6 +84,7 @@ function DefaultGroupEventDetailsContent({
}: Required) {
const organization = useOrganization();
const hasNewTagsUI = useHasNewTagsUI();
+ const tagsRef = useRef(null);
const projectSlug = project.slug;
const hasReplay = Boolean(event.tags?.find(({key}) => key === 'replayId')?.value);
@@ -129,7 +130,12 @@ function DefaultGroupEventDetailsContent({
project={project}
/>
)}
-
+
{!hasNewTagsUI && (
)}
@@ -164,7 +170,9 @@ function DefaultGroupEventDetailsContent({
{hasNewTagsUI && (
-
+
+
+
)}
diff --git a/static/app/views/settings/projectGeneralSettings/index.tsx b/static/app/views/settings/projectGeneralSettings/index.tsx
index f891e971893775..a6be61eb3ad8e2 100644
--- a/static/app/views/settings/projectGeneralSettings/index.tsx
+++ b/static/app/views/settings/projectGeneralSettings/index.tsx
@@ -306,14 +306,19 @@ class ProjectGeneralSettings extends DeprecatedAsyncView {