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} + + + } + > + + {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 {
-
- + {organization.features.includes('event-tags-tree-ui') && ( + + )}