diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5baea3ba65fdcb..9a7b27d8e79af5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -439,6 +439,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/loading/ @getsentry/app-frontend /static/app/components/events/interfaces/ @getsentry/app-frontend /static/app/components/forms/ @getsentry/app-frontend +/static/app/components/markdownTextArea.tsx @getsentry/app-frontend /static/app/locale.tsx @getsentry/app-frontend ## End of Frontend diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index b6247777dae29f..1f28e295e561c4 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -253,11 +253,6 @@ src/sentry/stacktraces/processing.py src/sentry/status_checks/__init__.py src/sentry/status_checks/base.py src/sentry/status_checks/warnings.py -src/sentry/synapse/__init__.py -src/sentry/synapse/endpoints/__init__.py -src/sentry/synapse/endpoints/authentication.py -src/sentry/synapse/endpoints/org_cell_mappings.py -src/sentry/synapse/paginator.py src/sentry/tagstore/__init__.py src/sentry/tagstore/base.py src/sentry/tagstore/exceptions.py @@ -643,18 +638,6 @@ static/app/components/events/eventTagsAndScreenshot/tags.tsx static/app/components/events/eventViewHierarchy.spec.tsx static/app/components/events/eventViewHierarchy.tsx static/app/components/events/eventXrayDiff.tsx -static/app/components/events/groupingInfo/groupingComponent.tsx -static/app/components/events/groupingInfo/groupingComponentChildren.tsx -static/app/components/events/groupingInfo/groupingComponentFrames.tsx -static/app/components/events/groupingInfo/groupingComponentStacktrace.tsx -static/app/components/events/groupingInfo/groupingInfo.tsx -static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx -static/app/components/events/groupingInfo/groupingInfoSection.tsx -static/app/components/events/groupingInfo/groupingSummary.tsx -static/app/components/events/groupingInfo/groupingVariant.spec.tsx -static/app/components/events/groupingInfo/groupingVariant.tsx -static/app/components/events/groupingInfo/useEventGroupingInfo.tsx -static/app/components/events/groupingInfo/utils.tsx static/app/components/events/meta/annotatedText/annotatedTextErrors.tsx static/app/components/events/meta/annotatedText/annotatedTextValue.tsx static/app/components/events/meta/annotatedText/filteredAnnotatedTextValue.tsx @@ -789,8 +772,6 @@ static/app/components/list/index.tsx static/app/components/list/listItem.tsx static/app/components/list/utils.tsx static/app/components/listGroup.tsx -static/app/components/loading/loadingContainer.spec.tsx -static/app/components/loading/loadingContainer.tsx static/app/components/loadingError.stories.tsx static/app/components/loadingError.tsx static/app/components/loadingIndicator.stories.tsx @@ -2258,7 +2239,6 @@ tests/sentry/receivers/outbox/test_control.py tests/sentry/receivers/test_analytics.py tests/sentry/receivers/test_core.py tests/sentry/receivers/test_data_forwarding.py -tests/sentry/receivers/test_default_detector.py tests/sentry/receivers/test_featureadoption.py tests/sentry/receivers/test_onboarding.py tests/sentry/receivers/test_releases.py @@ -2357,10 +2337,6 @@ tests/sentry/sudo/test_middleware.py tests/sentry/sudo/test_signals.py tests/sentry/sudo/test_utils.py tests/sentry/sudo/test_views.py -tests/sentry/synapse/__init__.py -tests/sentry/synapse/endpoints/__init__.py -tests/sentry/synapse/endpoints/test_org_cell_mappings.py -tests/sentry/synapse/test_paginator.py tests/sentry/tagstore/__init__.py tests/sentry/tagstore/test_types.py tests/sentry/tasks/__init__.py @@ -2535,7 +2511,6 @@ tests/social_auth/test_utils.py tests/tools/__init__.py tests/tools/test_api_urls_to_typescript.py tests/tools/test_bump_action.py -tests/tools/test_compute_selected_tests.py tests/tools/test_flake8_plugin.py tests/tools/test_lint_requirements.py tests/tools/test_pin_github_action.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 5dc8112c18489d..cfa5664e557e73 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1062_backfill_eventattachment_date_expires +sentry: 1063_remove_customdynamicsamplingrule social_auth: 0003_social_auth_json_field diff --git a/src/sentry/data_export/models.py b/src/sentry/data_export/models.py index 63effe0813c576..b6c991374d494a 100644 --- a/src/sentry/data_export/models.py +++ b/src/sentry/data_export/models.py @@ -169,7 +169,19 @@ def email_failure(self, message: str) -> None: error_payload=self.payload, creation_date=self.date_added, ) - if NotificationService.has_access(self.organization, data.source): + has_access = NotificationService.has_access(self.organization, data.source) + logger.info( + "notification.platform.data-export-failure.has_access", + extra={ + "organization_id": self.organization.id, + "data_export_id": self.id, + "data_source": data.source, + "has_access": has_access, + "user_email": user.email, + }, + ) + + if has_access: NotificationService(data=data).notify_async( targets=[ GenericNotificationTarget( diff --git a/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py b/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py new file mode 100644 index 00000000000000..23c3d41982f6df --- /dev/null +++ b/src/sentry/migrations/1063_remove_customdynamicsamplingrule.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.11 on 2026-04-02 +import django.db.models.deletion +from django.db import migrations + +import sentry.db.models.fields.foreignkey +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.models import SafeDeleteModel +from sentry.new_migrations.monkey.state import DeletionAction + + +class Migration(CheckedMigration): + is_post_deployment = False + + dependencies = [ + ("sentry", "1062_backfill_eventattachment_date_expires"), + ] + + operations = [ + migrations.AlterField( + model_name="customdynamicsamplingruleproject", + name="custom_dynamic_sampling_rule", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.customdynamicsamplingrule", + ), + ), + migrations.AlterField( + model_name="customdynamicsamplingruleproject", + name="project", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.project", + ), + ), + migrations.AlterField( + model_name="customdynamicsamplingrule", + name="organization", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.organization", + ), + ), + SafeDeleteModel( + name="CustomDynamicSamplingRuleProject", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + SafeDeleteModel( + name="CustomDynamicSamplingRule", + deletion_action=DeletionAction.MOVE_TO_PENDING, + ), + ] diff --git a/src/sentry/models/__init__.py b/src/sentry/models/__init__.py index f239ca916dcda7..5cb51e40e797d3 100644 --- a/src/sentry/models/__init__.py +++ b/src/sentry/models/__init__.py @@ -31,7 +31,6 @@ from .deletedteam import * # NOQA from .deploy import * # NOQA from .distribution import * # NOQA -from .dynamicsampling import * # NOQA from .environment import * # NOQA from .event import * # NOQA from .eventattachment import * # NOQA diff --git a/src/sentry/models/dynamicsampling.py b/src/sentry/models/dynamicsampling.py deleted file mode 100644 index 9ed171a1bc2524..00000000000000 --- a/src/sentry/models/dynamicsampling.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from django.db import models -from django.db.models import Q -from django.utils import timezone - -from sentry.backup.scopes import RelocationScope -from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model -from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey - - -@cell_silo_model -class CustomDynamicSamplingRuleProject(Model): - """ - Many-to-many relationship between a custom dynamic sampling rule and a project. - """ - - __relocation_scope__ = RelocationScope.Organization - - custom_dynamic_sampling_rule = FlexibleForeignKey( - "sentry.CustomDynamicSamplingRule", on_delete=models.CASCADE - ) - project = FlexibleForeignKey("sentry.Project", on_delete=models.CASCADE) - - class Meta: - app_label = "sentry" - db_table = "sentry_customdynamicsamplingruleproject" - unique_together = (("custom_dynamic_sampling_rule", "project"),) - - -@cell_silo_model -class CustomDynamicSamplingRule(Model): - """ - This represents a custom dynamic sampling rule that is created by the user based - on a query (a.k.a. investigation rule). - - """ - - __relocation_scope__ = RelocationScope.Organization - - date_added = models.DateTimeField(default=timezone.now) - organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) - projects = models.ManyToManyField( - "sentry.Project", - related_name="custom_dynamic_sampling_rules", - through=CustomDynamicSamplingRuleProject, - ) - is_active = models.BooleanField(default=True) - is_org_level = models.BooleanField(default=False) - rule_id = models.IntegerField(default=0) - condition = models.TextField() - sample_rate = models.FloatField(default=0.0) - start_date = models.DateTimeField(default=timezone.now) - end_date = models.DateTimeField() - num_samples = models.IntegerField() - condition_hash = models.CharField(max_length=40) - # the raw query field from the request - query = models.TextField(null=True) - created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE", null=True, blank=True) - notification_sent = models.BooleanField(null=True, blank=True) - - class Meta: - app_label = "sentry" - db_table = "sentry_customdynamicsamplingrule" - indexes = [ - # get active rules for an organization - models.Index(fields=["organization"], name="org_idx", condition=Q(is_active=True)), - # get expired rules (that are still marked as active) - models.Index(fields=["end_date"], name="end_date_idx", condition=Q(is_active=True)), - # find active rules for a condition - models.Index( - fields=["condition_hash"], name="condition_hash_idx", condition=Q(is_active=True) - ), - ] diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 2cdb2a01a3edc6..5b7c8db37cf3fd 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -20,7 +20,6 @@ from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser -from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -581,7 +580,6 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, diff --git a/src/sentry/preprod/grouptype.py b/src/sentry/preprod/grouptype.py index 8a34ada04615b2..e51ecd89089983 100644 --- a/src/sentry/preprod/grouptype.py +++ b/src/sentry/preprod/grouptype.py @@ -1,54 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass - import sentry.preprod.size_analysis.grouptype # noqa: F401,F403 -from sentry.issues.grouptype import GroupCategory, GroupType -from sentry.types.group import PriorityLevel # We have to import sentry.preprod.size_analysis.grouptype above. # grouptype modules in root packages (src/sentry/*) are auto imported # but more deeply nested ones are not. - - -@dataclass(frozen=True) -class PreprodStaticGroupType(GroupType): - """ - Issues detected in a single uploaded artifact. For example an - Android app not being 16kb page size ready. - Typically these end up grouped across multiple builds e.g. if CI - uploads a build of an app for each commit to main each of those - uploads could result in an occurrence of some issue like the 16kb - page size. - """ - - type_id = 11001 - slug = "preprod_static" - description = "Static Analysis" - category = GroupCategory.PREPROD.value - category_v2 = GroupCategory.PREPROD.value - default_priority = PriorityLevel.LOW - released = False - enable_auto_resolve = True - enable_escalation_detection = False - - -@dataclass(frozen=True) -class PreprodDeltaGroupType(GroupType): - """ - Issues detected examining the delta between two uploaded artifacts. - For example a binary size regression. These are typically *not* - grouped. A size regression between v1 and v2 likely does not have - the same root cause (and hence resolution) as another regression - between v2 and v3. - """ - - type_id = 11002 - slug = "preprod_delta" - description = "Static Analysis Delta" - category = GroupCategory.PREPROD.value - category_v2 = GroupCategory.PREPROD.value - default_priority = PriorityLevel.LOW - released = False - enable_auto_resolve = True - enable_escalation_detection = False diff --git a/src/sentry/profiles/task.py b/src/sentry/profiles/task.py index 6d74ac2cfeb2a4..981584567890b6 100644 --- a/src/sentry/profiles/task.py +++ b/src/sentry/profiles/task.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from operator import itemgetter from time import time -from typing import Any, TypedDict +from typing import Any from uuid import UUID import msgpack @@ -58,7 +58,6 @@ from sentry.profiles.utils import ( Profile, apply_stack_trace_rules_to_profile, - get_from_profiling_service, ) from sentry.search.utils import DEVICE_CLASS from sentry.signals import first_profile_received @@ -1075,62 +1074,6 @@ def _track_failed_outcome(profile: Profile, project: Project, reason: str) -> No ) -@metrics.wraps("process_profile.insert_vroom_profile") -def _insert_vroom_profile(profile: Profile) -> bool: - with sentry_sdk.start_span(op="task.profiling.insert_vroom"): - try: - path = "/chunk" if "profiler_id" in profile else "/profile" - response = get_from_profiling_service( - method="POST", - path=path, - json_data=profile, - metric=( - "profiling.profile.payload.size", - { - "type": "chunk" if "profiler_id" in profile else "profile", - "platform": profile["platform"], - }, - ), - ) - - sentry_sdk.set_tag("vroom.response.status_code", str(response.status)) - - reason = "bad status" - - if response.status == 204: - return True - elif response.status == 429: - reason = "gcs timeout" - elif response.status == 412: - reason = "duplicate profile" - - metrics.incr( - "process_profile.insert_vroom_profile.error", - tags={ - "platform": profile["platform"], - "reason": reason, - "status_code": response.status, - }, - sample_rate=1.0, - ) - return False - except Exception as e: - sentry_sdk.capture_exception(e) - metrics.incr( - "process_profile.insert_vroom_profile.error", - tags={"platform": profile["platform"], "reason": "encountered error"}, - sample_rate=1.0, - ) - return False - - -def _push_profile_to_vroom(profile: Profile, project: Project) -> bool: - if _insert_vroom_profile(profile=profile): - return True - _track_failed_outcome(profile, project, "profiling_failed_vroom_insertion") - return False - - def prepare_android_js_profile(profile: Profile) -> None: profile["js_profile"] = {"profile": profile["js_profile"]} p = profile["js_profile"] @@ -1152,11 +1095,6 @@ def clean_android_js_profile(profile: Profile) -> None: del p["dist"] -class _ProjectKeyKwargs(TypedDict): - project_id: int - use_case: str - - @metrics.wraps("process_profile.track_outcome") def _track_duration_outcome( profile: Profile, diff --git a/src/sentry/search/eap/preprod_size/attributes.py b/src/sentry/search/eap/preprod_size/attributes.py index 77d4cfa450883f..2e7085c2555e96 100644 --- a/src/sentry/search/eap/preprod_size/attributes.py +++ b/src/sentry/search/eap/preprod_size/attributes.py @@ -1,3 +1,5 @@ +from typing import Literal + from sentry.search.eap import constants from sentry.search.eap.columns import ResolvedAttribute, datetime_processor from sentry.search.eap.common_columns import COMMON_COLUMNS @@ -56,6 +58,11 @@ internal_name="git_head_ref", search_type="string", ), + ResolvedAttribute( + public_alias="installable", + internal_name="has_installable_file", + search_type="boolean", + ), ResolvedAttribute( public_alias="timestamp", internal_name="sentry.timestamp", @@ -65,3 +72,23 @@ ), ] } + +PREPROD_SIZE_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS: dict[ + Literal["string", "number", "boolean"], dict[str, str] +] = { + "string": { + definition.internal_name: definition.public_alias + for definition in PREPROD_SIZE_ATTRIBUTE_DEFINITIONS.values() + if not definition.secondary_alias and definition.search_type == "string" + }, + "boolean": { + definition.internal_name: definition.public_alias + for definition in PREPROD_SIZE_ATTRIBUTE_DEFINITIONS.values() + if not definition.secondary_alias and definition.search_type == "boolean" + }, + "number": { + definition.internal_name: definition.public_alias + for definition in PREPROD_SIZE_ATTRIBUTE_DEFINITIONS.values() + if not definition.secondary_alias and definition.search_type != "string" + }, +} diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 1da26a12a3809b..80cec07dcbfa87 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -16,6 +16,9 @@ OURLOG_ATTRIBUTE_DEFINITIONS, ) from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS +from sentry.search.eap.preprod_size.attributes import ( + PREPROD_SIZE_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, +) from sentry.search.eap.profile_functions.attributes import ( PROFILE_FUNCTIONS_ATTRIBUTE_DEFINITIONS, PROFILE_FUNCTIONS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, @@ -69,6 +72,7 @@ def add_start_end_conditions( SupportedTraceItemType.LOGS: LOGS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, SupportedTraceItemType.TRACEMETRICS: TRACE_METRICS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, SupportedTraceItemType.PROFILE_FUNCTIONS: PROFILE_FUNCTIONS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, + SupportedTraceItemType.PREPROD: PREPROD_SIZE_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS, } PUBLIC_ALIAS_TO_INTERNAL_MAPPING: dict[SupportedTraceItemType, dict[str, ResolvedAttribute]] = { diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 367131d81def82..08361e53317b55 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -428,16 +428,15 @@ def run_automation( return # Check event count for ALERT source with seat-based tier - if is_seer_seat_based_tier_enabled(group.organization): - if source == SeerAutomationSource.ALERT: - # Use times_seen_with_pending if available (set by post_process), otherwise fall back - times_seen = ( - group.times_seen_with_pending - if hasattr(group, "_times_seen_pending") - else group.times_seen - ) - if times_seen < AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD: - return + if is_seer_seat_based_tier_enabled(group.organization) and source == SeerAutomationSource.ALERT: + # Use times_seen_with_pending if available (set by post_process), otherwise fall back + times_seen = ( + group.times_seen_with_pending + if hasattr(group, "_times_seen_pending") + else group.times_seen + ) + if times_seen < AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD: + return user_id = user.id if user else None auto_run_source = auto_run_source_map.get(source, "unknown_source") diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index 3b980ccdc62eef..f000b211a16f6b 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -3,7 +3,7 @@ import io import tempfile from copy import deepcopy -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from functools import cached_property, cmp_to_key from pathlib import Path from typing import Any @@ -77,10 +77,6 @@ DashboardWidgetQueryOnDemand, DashboardWidgetTypes, ) -from sentry.models.dynamicsampling import ( - CustomDynamicSamplingRule, - CustomDynamicSamplingRuleProject, -) from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewProject @@ -819,20 +815,6 @@ def create_exhaustive_organization( overrides={"write_key": "test_override_write_key"}, ) - custom_rule = CustomDynamicSamplingRule.objects.create( - organization=org, - created_by_id=owner_id, - condition='{"op":"and","inner":[]}', - end_date=timezone.now() + timedelta(days=1), - num_samples=100, - condition_hash="abc123def456abc123def456abc123def4560000", - sample_rate=0.5, - ) - CustomDynamicSamplingRuleProject.objects.create( - custom_dynamic_sampling_rule=custom_rule, - project=project, - ) - return org @assume_test_silo_mode(SiloMode.CONTROL) diff --git a/static/AGENTS.md b/static/AGENTS.md index f5a441d194e866..e95be6804f40c5 100644 --- a/static/AGENTS.md +++ b/static/AGENTS.md @@ -60,6 +60,32 @@ const query = useQuery( Existing code might use `useApiQuery` from `sentry/utils/queryClient` — prefer `apiOptions` for new code. +#### Accessing response headers (pagination, hit counts) + +By default, `apiOptions` selects only the JSON body from the response. If you need response headers (e.g., `Link` for pagination or `X-Hits` / `X-Max-Hits` for total counts), override `select` with `selectJsonWithHeaders`: + +```typescript +import {useQuery} from '@tanstack/react-query'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; + +const {data} = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/items/', { + path: {organizationIdOrSlug: organization.slug}, + query: {cursor, per_page: 25}, + staleTime: 0, + }), + select: selectJsonWithHeaders, +}); + +// data is ApiResponse — an object with `json` and `headers` +const items = data?.json ?? []; +const pageLinks = data?.headers.Link; // string | undefined +const totalHits = data?.headers['X-Hits']; // number | undefined +const maxHits = data?.headers['X-Max-Hits']; // number | undefined +``` + +Note that `X-Hits` and `X-Max-Hits` are already parsed to `number | undefined` — no `parseInt` needed. + ## General Frontend Rules 1. NO new Reflux stores diff --git a/static/app/components/arithmeticBuilder/action.tsx b/static/app/components/arithmeticBuilder/action.tsx index 5c0a864edabd1b..494fb1dbabc0ab 100644 --- a/static/app/components/arithmeticBuilder/action.tsx +++ b/static/app/components/arithmeticBuilder/action.tsx @@ -51,11 +51,13 @@ function isArithmeticBuilderReplaceAction( interface UseArithmeticBuilderActionOptions { initialExpression: string; + references?: Set; updateExpression?: (expression: Expression) => void; } export function useArithmeticBuilderAction({ initialExpression, + references, updateExpression, }: UseArithmeticBuilderActionOptions): { dispatch: (action: ArithmeticBuilderAction) => void; @@ -64,30 +66,38 @@ export function useArithmeticBuilderAction({ focusOverride: FocusOverride | null; }; } { - const [expression, setExpression] = useState(() => new Expression(initialExpression)); + const [expressionString, setExpressionString] = useState(initialExpression); const [focusOverride, setFocusOverride] = useState(null); + // Recreate the Expression when the string or references change because + // a reference change may invalidate some of the current references and turn + // them into free text tokens. + const expression = useMemo( + () => new Expression(expressionString, references), + [expressionString, references] + ); + const dispatch = useCallback( (action: ArithmeticBuilderAction) => { if (isArithmeticBuilderUpdateResetFocusOverrideAction(action)) { setFocusOverride(null); } else if (isArithmeticBuilderDeleteAction(action)) { - const newExpression = deleteToken(expression.text, action); - updateExpression?.(newExpression); - setExpression(newExpression); + const newText = deleteTokenText(expressionString, action); + setExpressionString(newText); + updateExpression?.(new Expression(newText, references)); if (defined(action.focusOverride)) { setFocusOverride(action.focusOverride); } } else if (isArithmeticBuilderReplaceAction(action)) { - const newExpression = replaceToken(expression.text, action); - updateExpression?.(newExpression); - setExpression(newExpression); + const newText = replaceTokenText(expressionString, action); + setExpressionString(newText); + updateExpression?.(new Expression(newText, references)); if (defined(action.focusOverride)) { setFocusOverride(action.focusOverride); } } }, - [expression.text, updateExpression] + [expressionString, references, updateExpression] ); const state = useMemo( @@ -101,14 +111,14 @@ export function useArithmeticBuilderAction({ return {state, dispatch}; } -function deleteToken(text: string, action: ArithmeticBuilderDeleteAction) { +function deleteTokenText(text: string, action: ArithmeticBuilderDeleteAction): string { const [head, tail] = queryHeadTail(text, action.token); - return new Expression(removeExcessWhitespaceFromParts(head, tail)); + return removeExcessWhitespaceFromParts(head, tail); } -function replaceToken(text: string, action: ArithmeticBuilderReplaceAction) { +function replaceTokenText(text: string, action: ArithmeticBuilderReplaceAction): string { const [head, tail] = queryHeadTail(text, action.token); - return new Expression(removeExcessWhitespaceFromParts(head, action.text, tail)); + return removeExcessWhitespaceFromParts(head, action.text, tail); } function queryHeadTail(expression: string, token: Token): [string, string] { diff --git a/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx b/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx new file mode 100644 index 00000000000000..76120e6025cc92 --- /dev/null +++ b/static/app/components/arithmeticBuilder/arithmeticBuilder.stories.tsx @@ -0,0 +1,89 @@ +import {Fragment, useCallback, useEffect, useState} from 'react'; + +import {ArithmeticBuilder} from 'sentry/components/arithmeticBuilder'; +import {Expression} from 'sentry/components/arithmeticBuilder/expression'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; +import * as Storybook from 'sentry/stories'; + +export default Storybook.story('ArithmeticBuilder', story => { + story('With References', () => { + const [expression, setExpression] = useState('A + B'); + const [isValid, setIsValid] = useState(true); + const [references, setReferences] = useState(new Set(['A', 'B', 'C'])); + const [parseError, setParseError] = useState(''); + + const onExpressionChange = useCallback((expr: Expression) => { + setExpression(expr.text); + }, []); + + // Explicitly check the new expression for validity since references + // changing is a responibility of the caller. + useEffect(() => { + setIsValid(new Expression(expression, references).isValid); + }, [expression, references]); + + return ( + +

+ Define references as a JSON array of strings below, then use them in the + expression. If references are present, then they will take priority over + aggregations and only suggest references and operators when typing. +

+ +

+ If a character appears that is not a reference, then it will be treated as a + free text token. +

+ +

+ If during typing, the string matches a single reference, we will automatically + select that reference. Otherwise we will continue to suggest references since + there are multiple options. +

+ +