From d2ff0fa66d9b8fac52815b75bfa9645fb144f6c7 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Thu, 7 Nov 2024 22:35:31 -0800 Subject: [PATCH 1/9] :sparkles: add modal --- .../featureFlags/eventFeatureFlagList.tsx | 94 +++++--- .../events/featureFlags/featureFlagSort.tsx | 1 + .../featureFlags/setupIntegrationModal.tsx | 227 ++++++++++++++++++ 3 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 static/app/components/events/featureFlags/setupIntegrationModal.tsx diff --git a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx index ae0c7aba42a188..9b7c0ec9273b18 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx @@ -1,14 +1,20 @@ -import {useCallback, useMemo, useRef, useState} from 'react'; +import {Fragment, useCallback, useMemo, useRef, useState} from '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 EmptyStateWarning from 'sentry/components/emptyStateWarning'; import ErrorBoundary from 'sentry/components/errorBoundary'; import { CardContainer, FeatureFlagDrawer, } from 'sentry/components/events/featureFlags/featureFlagDrawer'; import FeatureFlagSort from 'sentry/components/events/featureFlags/featureFlagSort'; +import { + modalCss, + SetupIntegrationModal, +} from 'sentry/components/events/featureFlags/setupIntegrationModal'; import { FlagControlOptions, OrderBy, @@ -86,6 +92,15 @@ export function EventFeatureFlagList({ event, }); + const hasFlagContext = !!event.contexts.flags; + const hasFlags = Boolean(hasFlagContext && event?.contexts?.flags?.values.length); + + function handleClick() { + openModal(modalProps => , { + modalCss, + }); + } + const suspectFlagNames: Set = useMemo(() => { return isSuspectError || isSuspectPending ? new Set() @@ -150,37 +165,50 @@ export function EventFeatureFlagList({ [openDrawer, event, group, project, hydratedFlags, organization, sortBy, orderBy] ); - if (!hydratedFlags.length) { + // TODO: for LD users, show a CTA in this section instead + // if contexts.flags is not set, hide the section + if (!hasFlagContext) { return null; } const actions = ( {feedbackButton} - - + {hasFlags && ( + + + + + + ); + }, [Footer, handleSubmit, state]); + + const ModalBody = useCallback( + ({children: bodyChildren}: Parameters['Body']>[0]) => { + return {bodyChildren}; + }, + [Body] + ); + + const integrations = ['LaunchDarkly']; + + return ( + + {t('Set Up Feature Flag Integration')} + + + ({ + value: integration, + label: integration, + }))} + placeholder={t('Select a feature flag service')} + value={state.provider} + onChange={value => setState({...state, provider: value})} + flexibleControlStateSize + stacked + required + /> + { + const newToken = await token; + const provider = state.provider.toLowerCase(); + const encodedToken = encodeURI(newToken); + + setState(prevState => { + return { + ...prevState, + url: `https://sentry.io/api/0/organizations/${organization?.slug}/flags/hooks/provider/${provider}/token/${encodedToken}/`, + }; + }); + }} + disabled={!defined(state.provider) || defined(state.url)} + > + {t('Create Webhook URL')} + + + + {t('Webhook URL')} + + {state.url ?? ''} + + + {t( + 'The final step is to create a Webhook integration within your Feature flag service by utilizing the Webhook URL provided in the field above.' + )} + }> + {t('You won’t be able to access this URL once this modal is closed.')} + + + + + + + ); +} + +export const modalCss = css` + width: 100%; + max-width: 680px; +`; + +const StyledButtonBar = styled(ButtonBar)` + display: flex; + width: 100%; + justify-content: space-between; +`; + +const SelectContainer = styled('div')` + display: grid; + grid-template-columns: 1fr max-content; + align-items: center; + gap: ${space(1)}; +`; + +const WebhookButton = styled(Button)` + margin-top: ${space(1)}; +`; + +const WebhookContainer = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1)}; +`; + +const InfoContainer = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(2)}; + margin-top: ${space(1)}; +`; From 3ef2ca2c5a7435ff73c6380c7a8403bb858a1799 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Thu, 7 Nov 2024 22:43:02 -0800 Subject: [PATCH 2/9] :recycle: tweaks --- .../events/featureFlags/eventFeatureFlagList.tsx | 2 +- .../events/featureFlags/setupIntegrationModal.tsx | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx index 9b7c0ec9273b18..b26851c3669151 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx @@ -237,7 +237,7 @@ export function EventFeatureFlagList({ ) : ( - + {t('No feature flags were found for this event')} )} diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx index 1b921e4d9acfc0..aa6916bc7f920c 100644 --- a/static/app/components/events/featureFlags/setupIntegrationModal.tsx +++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx @@ -1,7 +1,6 @@ import {Fragment, useCallback, useState} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import type {Event} from '@sentry/types'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; @@ -20,19 +19,9 @@ import {defined} from 'sentry/utils'; import useApi from 'sentry/utils/useApi'; export type ChildrenProps = { - Body: (props: { - children: React.ReactNode; - showSelfHostedMessage?: boolean; - }) => ReturnType; - Footer: (props: { - onBack?: () => void; - onNext?: () => void; - primaryDisabledReason?: string; - secondaryAction?: React.ReactNode; - submitEventData?: Event; - }) => ReturnType; + Body: (props: {children: React.ReactNode}) => ReturnType; + Footer: () => ReturnType; Header: (props: {children: React.ReactNode}) => ReturnType; - onFieldChange: (field: Field, value: T[Field]) => void; state: T; }; From 4cba2e68eec92af97a051b1e1bf7d8876ad23073 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Thu, 7 Nov 2024 23:00:22 -0800 Subject: [PATCH 3/9] :bug: only call endpoint once --- .../featureFlags/setupIntegrationModal.tsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx index aa6916bc7f920c..4965d523de8ebd 100644 --- a/static/app/components/events/featureFlags/setupIntegrationModal.tsx +++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx @@ -30,30 +30,30 @@ interface State { url: string | undefined; } -async function useGenerateAuthToken(state: State) { +function useGenerateAuthToken(state: State) { const api = useApi(); const date = new Date().toISOString(); - const newToken: any = await api.requestPromise(`/api-tokens/`, { - method: 'POST', - data: { - name: `${state.provider} Token ${date}`, - scopes: [ - 'project:read', - 'project:write', - 'project:admin', - 'event:read', - 'event:write', - 'event:admin', - 'org:read', - 'org:write', - 'org:admin', - ], - }, - }); - - const token = newToken.token; - return token; + const createToken = async () => + await api.requestPromise(`/api-tokens/`, { + method: 'POST', + data: { + name: `${state.provider} Token ${date}`, + scopes: [ + 'project:read', + 'project:write', + 'project:admin', + 'event:read', + 'event:write', + 'event:admin', + 'org:read', + 'org:write', + 'org:admin', + ], + }, + }); + + return {createToken}; } export function SetupIntegrationModal({ @@ -67,7 +67,7 @@ export function SetupIntegrationModal({ url: undefined, }); const {organization} = useLegacyStore(OrganizationStore); - const token = useGenerateAuthToken(state); + const {createToken} = useGenerateAuthToken(state); const handleSubmit = useCallback(() => { addSuccessMessage(t('Integration set up successfully')); @@ -138,9 +138,9 @@ export function SetupIntegrationModal({ priority="default" title={!defined(state.provider) && t('You must select a provider first.')} onClick={async () => { - const newToken = await token; + const newToken = await createToken(); + const encodedToken = encodeURI(newToken.token); const provider = state.provider.toLowerCase(); - const encodedToken = encodeURI(newToken); setState(prevState => { return { From fd1edb3211cf073ad1ea262aa632ba307c3c1a77 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Thu, 7 Nov 2024 23:39:28 -0800 Subject: [PATCH 4/9] :white_check_mark: tests --- .../eventFeatureFlagList.spec.tsx | 28 +++++++++++++++++++ .../featureFlags/setupIntegrationModal.tsx | 2 +- .../events/featureFlags/testUtils.tsx | 18 ++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/static/app/components/events/featureFlags/eventFeatureFlagList.spec.tsx b/static/app/components/events/featureFlags/eventFeatureFlagList.spec.tsx index 1d8b60d89569c1..bfb0c51acb982c 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagList.spec.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagList.spec.tsx @@ -9,8 +9,10 @@ import { import {EventFeatureFlagList} from 'sentry/components/events/featureFlags/eventFeatureFlagList'; import { + EMPTY_STATE_SECTION_PROPS, MOCK_DATA_SECTION_PROPS, MOCK_FLAGS, + NO_FLAG_CONTEXT_SECTION_PROPS, } from 'sentry/components/events/featureFlags/testUtils'; // Needed to mock useVirtualizer lists. @@ -192,4 +194,30 @@ describe('EventFeatureFlagList', function () { .compareDocumentPosition(screen.getByText(enableReplay.flag)) ).toBe(document.DOCUMENT_POSITION_FOLLOWING); }); + + it('renders empty state', function () { + render(); + + const control = screen.queryByRole('button', {name: 'Sort Flags'}); + expect(control).not.toBeInTheDocument(); + const search = screen.queryByRole('button', {name: 'Open Feature Flag Search'}); + expect(search).not.toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Set Up Integration'})).toBeInTheDocument(); + expect( + screen.queryByText('No feature flags were found for this event') + ).toBeInTheDocument(); + }); + + it('renders nothing if event.contexts.flags is not set', function () { + render(); + + const control = screen.queryByRole('button', {name: 'Sort Flags'}); + expect(control).not.toBeInTheDocument(); + const search = screen.queryByRole('button', {name: 'Open Feature Flag Search'}); + expect(search).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Set Up Integration'}) + ).not.toBeInTheDocument(); + expect(screen.queryByText('Feature Flags')).not.toBeInTheDocument(); + }); }); diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx index 4965d523de8ebd..b215188b6437fc 100644 --- a/static/app/components/events/featureFlags/setupIntegrationModal.tsx +++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx @@ -167,7 +167,7 @@ export function SetupIntegrationModal({ {t( - 'The final step is to create a Webhook integration within your Feature flag service by utilizing the Webhook URL provided in the field above.' + 'The final step is to create a Webhook integration within your feature flag service by utilizing the Webhook URL provided in the field above.' )} }> {t('You won’t be able to access this URL once this modal is closed.')} diff --git a/static/app/components/events/featureFlags/testUtils.tsx b/static/app/components/events/featureFlags/testUtils.tsx index cdc932dab1f628..e4de5c67748a87 100644 --- a/static/app/components/events/featureFlags/testUtils.tsx +++ b/static/app/components/events/featureFlags/testUtils.tsx @@ -31,3 +31,21 @@ export const MOCK_DATA_SECTION_PROPS = { project: ProjectFixture(), group: GroupFixture(), }; + +export const EMPTY_STATE_SECTION_PROPS = { + event: EventFixture({ + id: 'abc123def456ghi789jkl', + contexts: {flags: {values: []}}, + }), + project: ProjectFixture(), + group: GroupFixture(), +}; + +export const NO_FLAG_CONTEXT_SECTION_PROPS = { + event: EventFixture({ + id: 'abc123def456ghi789jkl', + contexts: {other: {}}, + }), + project: ProjectFixture(), + group: GroupFixture(), +}; From 60e0c97c39710c9a32277a779299540bd3230ae0 Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:36:51 -0800 Subject: [PATCH 5/9] :recycle: add docs link --- .../events/featureFlags/setupIntegrationModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx index b215188b6437fc..8ce3d5c050ae04 100644 --- a/static/app/components/events/featureFlags/setupIntegrationModal.tsx +++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx @@ -89,7 +89,11 @@ export function SetupIntegrationModal({ return (
- + {t('Read Docs')} {hasFlags && ( diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx index c1b4baea9e9a97..3febc7a350e002 100644 --- a/static/app/components/events/featureFlags/setupIntegrationModal.tsx +++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx @@ -65,7 +65,7 @@ export function SetupIntegrationModal({ const {organization} = useLegacyStore(OrganizationStore); const {createToken} = useGenerateAuthToken({state, orgSlug: organization?.slug}); - const handleSubmit = useCallback(() => { + const handleDone = useCallback(() => { addSuccessMessage(t('Integration set up successfully')); closeModal(); }, [closeModal]); @@ -95,7 +95,7 @@ export function SetupIntegrationModal({
); - }, [Footer, handleSubmit, state]); + }, [Footer, handleDone, state]); const ModalBody = useCallback( ({children: bodyChildren}: Parameters['Body']>[0]) => { @@ -112,7 +112,22 @@ export function SetupIntegrationModal({ [Body] ); - const integrations = ['LaunchDarkly']; + const onGenerateURL = useCallback(async () => { + const newToken = await createToken(); + const encodedToken = encodeURI(newToken.token); + const provider = state.provider.toLowerCase(); + + setState(prevState => { + return { + ...prevState, + url: `https://sentry.io/api/0/organizations/${organization?.slug}/flags/hooks/provider/${provider}/token/${encodedToken}/`, + }; + }); + + trackAnalytics('flags.webhook_url_generated', {organization}); + }, [createToken, organization, state.provider]); + + const providers = ['LaunchDarkly']; return ( @@ -123,7 +138,7 @@ export function SetupIntegrationModal({ label={t('Feature Flag Services')} name="provider" inline={false} - options={integrations.map(integration => ({ + options={providers.map(integration => ({ value: integration, label: integration, }))} @@ -137,20 +152,7 @@ export function SetupIntegrationModal({ { - const newToken = await createToken(); - const encodedToken = encodeURI(newToken.token); - const provider = state.provider.toLowerCase(); - - setState(prevState => { - return { - ...prevState, - url: `https://sentry.io/api/0/organizations/${organization?.slug}/flags/hooks/provider/${provider}/token/${encodedToken}/`, - }; - }); - - trackAnalytics('flags.webhook_url_generated', {organization}); - }} + onClick={onGenerateURL} disabled={!defined(state.provider) || defined(state.url)} > {t('Create Webhook URL')} diff --git a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx index b9c5bea9b57c0a..ebb162119e9cae 100644 --- a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx +++ b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx @@ -20,6 +20,6 @@ export const featureFlagEventMap: Record = { 'flags.sort-flags': 'Sorted Flags', 'flags.event_and_suspect_flags_found': 'Number of Event and Suspect Flags', 'flags.setup_modal_opened': 'Flag Setup Integration Modal Opened', - 'flags.webhook_url_generated': 'Flag Webhook URL Generated in Modal', + 'flags.webhook_url_generated': 'Flag Webhook URL Generated in Setup Integration Modal', 'flags.table_rendered': 'Flag Table Rendered', };