feat(forms): Migrate Sentry App schema forms behind a flag#114953
Conversation
📊 Type Coverage Diff
🔍 12 new type safety issues introduced
Non-null assertions (
Type assertions (
| This is informational only and does not block the PR. |
Migrate the Sentry App schema-backed forms (issue-link and
alert-rule-action) off FormModel/FieldFromConfig and onto
BackendJsonSubmitForm. SentryAppExternalForm becomes a function
component; both consumers (SentryAppExternalIssueForm,
SentryAppRuleModal) switch to the named export.
Add a customAsyncQueryOptions prop on BackendJsonSubmitForm so
consumers can override the default URL-based async-select fetcher.
The Sentry App external-requests endpoint expects {uri, query,
dependentData} which doesn't fit the adapter's built-in shape, and
this is the actual consumer the prop was added for.
Add an onValueChange callback so the migration can mirror every
field change into its own ref/state for dependent-field tracking,
distinct from onFieldChange (which only fires for updatesForm fields).
Preserve legacy behavior where it matters: depends_on dependent
field loading and resets, backend-provided default values, saved
labels for async selects, alert-rule submit payload shape, and the
form reset on action prop change.
Defer required-field validation to the form's auto-generated zod
schema. The submit button stays enabled; clicking surfaces inline
per-field errors via aria-invalid + the design-system warning icon,
matching the pattern in ticketRuleModal and externalIssueForm.
Refs DE-1055
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The mutation's onError checked '!(error instanceof Error)', which was always false because RequestError extends Error — the fallback toast never ran. Type the mutation's TError as Error and check '!(error instanceof RequestError)' so the fallback only fires for non-RequestError errors. RequestError flows through BackendJsonSubmitForm's catch (which surfaces the API's detail message), so this avoids duplicate toasts while still covering non-RequestError cases that BackendJsonSubmitForm silently swallows. Refs DE-1055 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop the (field.choices?.length ?? 0) > 0 guard from customAsyncQueryOptions. Any select with a uri now goes through the async path, with field.choices seeded as initialData (shown before typing) and the uri fetched on typing. The backend schema validator's oneOf rule already prevents production Sentry App schemas from declaring both uri and choices, so this only changes behavior for fixtures and improves coverage for any future schema that wants both. Refs DE-1055 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolve schema defaults and request payload inputs by value so the initialization effect only reruns when the form semantics change. This avoids clearing dependent field state when callers rerender with equivalent callback and object props. Memoize the issue-link and alert-rule callers that were recreating those props on every render and add a rerender regression test for dependent field prefetches. Co-Authored-By: OpenAI Codex <noreply@openai.com>
Keep the rerender reset protection in SentryAppExternalForm while moving the issue-link and alert-rule callers back to inline props. This keeps the change focused on the shared form instead of spreading memoization churn across wrappers. Replace the JSON parse normalization with a small serialized-value memo helper so equivalent object props still compare by value without the extra parse step. Co-Authored-By: OpenAI Codex <noreply@openai.com>
Keep dependent field refreshes from overwriting newer form input by invalidating stale async responses and rebuilding remount values from the latest live form state. Also normalize async select choice values before caching labels and forward textarea autosize metadata through the adapter so migrated schema forms keep their intended behavior. Co-Authored-By: OpenAI Codex <noreply@openai.com>
Restore the pre-migration class component as sentryAppExternalForm.legacy.tsx (exported as LegacySentryAppExternalForm) and have the two consumers — SentryAppExternalIssueForm and SentryAppRuleModal — pick between the legacy and new form based on the organizations:sentry-app-schema-form-migration flag. Add Sentry.captureException to the new form's two failure paths so we can observe issues during rollout: - Save: onError of the create-external-issue mutation - Edit: dependent-field initialization (Promise.all of fetchFieldChoices when the form opens) The flag is registered in #114945 and rolled out via getsentry/sentry-options-automator#7678. Refs DE-1055 Refs #114945 Refs #112911 Co-Authored-By: Claude <noreply@anthropic.com>
Replace the any escape with the canonical FieldFromSchema type exported from sentryAppExternalForm.tsx, which has all the fields the helper reads or assigns (type, default, maxRows, autosize).
106d769 to
9440af7
Compare
|
🚨 Warning: This pull request contains Frontend and Backend changes! It's discouraged to make changes to Sentry's Frontend and Backend in a single pull request. The Frontend and Backend are not atomically deployed. If the changes are interdependent of each other, they must be separated into two pull requests and be made forward or backwards compatible, such that the Backend or Frontend can be safely deployed independently. Have questions? Please ask in the |
Restore the four spec files to their master state and drop the new sentryAppExternalForm.spec.tsx, per request to keep this PR focused on the migration + flag gating without new test coverage.
Restore sentryAppExternalForm.tsx to its master state byte-for-byte and move the new BackendJsonSubmitForm-based component to sentryAppExternalForm.new.tsx (exported as SentryAppExternalFormNew). Delete sentryAppExternalForm.legacy.tsx. This makes the diff against master much easier to review: the existing file is untouched, and the new code stands alone as a brand-new file.
Three small fixes after swapping the file naming:
- Drop the FieldFromSchema/SchemaFormConfig type exports from the
.new.tsx file. Consumers don't need them and knip was flagging
them as unused exports.
- Cast config/getFieldDefault/onSubmitSuccess at the SentryAppExternalFormNew
boundary in both consumers. The legacy file (now master canonical)
exports its own Field-derived types that don't structurally match
the .new.tsx narrower types, so explicit casts are needed at the
call sites that pass legacy-typed values to the new component.
- Narrow the single-select SelectAsync value/onChange types in
backendJsonSubmitForm.tsx from string|number to string. The earlier
rebase merge resolution kept master's wider 'string|number' typing,
but SelectAsync's value prop is 'string|string[]|null' so the wider
type didn't compile.
AsyncSelectQueryOptions in BackendJsonSubmitForm:
- TQueryFnData: any -> Array<SelectValue<string>> (matches what every
queryFn actually returns)
- TData: ReadonlyArray<SelectValue<string>> -> Array<SelectValue<string>>
(no select transformation, so it equals TQueryFnData)
- TQueryKey: kept as any with a comment explaining why — TanStack's
contravariant Enabled<...,TQueryKey> rejects any wider type when
factories build literal-tuple query keys.
sentryAppExternalForm.new.tsx:
- OnSubmitSuccess.response: any -> unknown
- ResetValues index signature: any -> unknown
sentryAppExternalIssueForm.tsx:
- handleSubmitSuccess now takes (response: unknown) and casts to
PlatformExternalIssue inside, since the form no longer hands back
a typed response.
|
bugbot run |
Both FieldFromSchema variants (legacy and new) share the few fields getFieldDefault actually reads and writes. Narrow the parameter to that minimal shape so the same function satisfies both form props via contravariance, and drop the ComponentProps cast at the call site.
|
bugbot run |
choicesByField was spreading asyncOptionsCache after lookup, so any field the user had searched lost its initial choices entirely. The async cache only stores the last non-empty search and is never cleared, so after a user searched, cleared the input, then selected an initial option, handleSubmit could not resolve a label for it — the saved setting omitted the label and the alert rule modal redisplayed a raw value on reopen. Union the two per-field instead, with initial choices first and any search results that aren't already represented appended after.
|
bugbot run |
When action toggles between create and link, the parent typically swaps to a different schema and resolvedFieldGroups changes — but if the two schemas happen to be structurally identical, the serialized memo keeps the same reference and the reset effect never fires. formKey still flips (it includes action), so BackendJsonSubmitForm remounts, but currentFormValuesRef and the other state mirrors keep the previous action's values, poisoning dependentData on the next cascade fetch. Add action to the reset effect's deps so the form's identity (action × config × element) drives the reset, matching the legacy class component's componentDidUpdate behavior.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ffff2c7. Configure here.
handleFieldChange only fetched fields directly listed in the changed trigger's depends_on. If field B got a default value from that fetch and field C had B in its depends_on, C never refetched — so chained schemas (A → B → C) silently degraded compared to the legacy form, which cascaded naturally because each model.setValue fired its own onFieldChange. Walk the dependency graph BFS-style: each level fetches its dependent fields; any field that comes back with a defaultValue is queued as the trigger for the next level. processedTriggers and fetchedFields guard against diamonds and cycles, and a snapshot of working state per iteration keeps the in-loop closure safe under no-loop-func.
manual QA on the new sentry app schema form surfaced several bugs the existing single-test suite did not catch. fix them together so the migration ships with working cascade, loading, and error UX: - cascade transitively on mount, not just on click. extract the BFS into a shared cascadeFetchDependents helper used by both the click cascade and the mount-time prefetch so chained schemas (A -> B -> C) populate when a parent has a baked-in default. - preserve the user's just-picked trigger value in the click cascade by only applying newly-resolved defaults to fields that were actually impacted, instead of spreading the full defaultValues over the user's choice. - drop the staging step in handleFieldChange. it triggered a mid-flight remount that bumped dependentFetchVersionRef and silently aborted the cascade after the dependent fetch returned. - split the dependent-fetch flag into isFetchingInitialCascade (drives the whole-form LoadingIndicator) and isFetchingDependentFields (only disables submit during click cascades) so clicking a select no longer blanks the form. - preserve saved/initial values through the mount cascade in both the BFS (don't overwrite workingValues[name] when it already has a value) and the prefetch effect (use cascadeResult.values directly instead of spreading defaultValues over nextInitialValues). fixes alert-rule-action settings round-tripping from the DB. - surface a toast when the mount cascade fails. previously the failure was silent and only logged to Sentry. - rewrite the error toast in plain language: "Couldn't load options for some fields." (was "Unable to load dependent options.") Co-Authored-By: Claude <noreply@anthropic.com>
textareas without a schema `default` skipped maxRows + autosize normalization because cloneSchemaFields only invoked getFieldDefault (where the mutation lived as a side effect) when `default` was set. the result: textarea fields with a default rendered with maxRows=10 and autosize, but a sibling textarea without `default` rendered unbounded — inconsistent within the same form. apply the textarea defaults inside cloneSchemaFields itself so they land regardless of whether the field has a `default`. honor any schema-provided maxRows/autosize values when present. side effect of getFieldDefault is now redundant for the new form but left in place so the legacy form (still flag-gated and being deprecated) keeps its prior behavior. also fixes the alert-rule-action path, where no getFieldDefault is passed and textareas previously never got these defaults. Co-Authored-By: Claude <noreply@anthropic.com>
the cascade BFS fetches issue-types only after the boards fetch resolves with a defaultValue, so waiting on the transitive call implies the parent already ran. one waitFor + a synchronous assertion is enough, with the bonus that a timeout points unambiguously at the transitive step the test is exercising. Co-Authored-By: Claude <noreply@anthropic.com>
Migrate the Sentry App schema-backed forms to `BackendJsonSubmitForm`, behind the [`organizations:sentry-app-schema-form-migration`](#114945) flag. `SentryAppExternalForm` becomes a function component and renders via `BackendJsonSubmitForm`. Both consumers (`SentryAppExternalIssueForm`, `SentryAppRuleModal`) keep the same Sentry App schema form path. Adds a `customAsyncQueryOptions` prop on `BackendJsonSubmitForm` so consumers can override the default URL-based async-select fetcher. The Sentry App `external-requests` endpoint expects `{uri, query, dependentData}`, which doesn't fit the adapter's built-in shape. The new implementation lives at `sentryAppExternalForm.new.tsx`; the pre-migration class component stays at `sentryAppExternalForm.tsx` and is gated behind the flag. `Sentry.captureException` is added to the new form's two user-visible failure paths (save mutation `onError`, dependent-field initialization on open) for rollout observability. closes https://linear.app/getsentry/issue/DE-1055/sentry-app-schema-forms-custom-uris --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <noreply@openai.com>

Migrate the Sentry App schema-backed forms to
BackendJsonSubmitForm, behind theorganizations:sentry-app-schema-form-migrationflag.SentryAppExternalFormbecomes a function component and renders viaBackendJsonSubmitForm. Both consumers (SentryAppExternalIssueForm,SentryAppRuleModal) keep the same Sentry App schema form path.Adds a
customAsyncQueryOptionsprop onBackendJsonSubmitFormso consumers can override the default URL-based async-select fetcher. The Sentry Appexternal-requestsendpoint expects{uri, query, dependentData}, which doesn't fit the adapter's built-in shape.The new implementation lives at
sentryAppExternalForm.new.tsx; the pre-migration class component stays atsentryAppExternalForm.tsxand is gated behind the flag.Sentry.captureExceptionis added to the new form's two user-visible failure paths (save mutationonError, dependent-field initialization on open) for rollout observability.closes https://linear.app/getsentry/issue/DE-1055/sentry-app-schema-forms-custom-uris