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}
-
- }
- size="xs"
- title={t('Open Search')}
- onClick={() => onViewAllFlags(FlagControlOptions.SEARCH)}
- />
-
+ {hasFlagContext && (
+
+
+ {hasFlags && (
+
+
+ }
+ size="xs"
+ title={t('Open Search')}
+ onClick={() => onViewAllFlags(FlagControlOptions.SEARCH)}
+ />
+
+
+ )}
+
+ )}
);
@@ -203,10 +231,16 @@ export function EventFeatureFlagList({
type={SectionKey.FEATURE_FLAGS}
actions={actions}
>
-
-
-
-
+ {hasFlags ? (
+
+
+
+
+ ) : (
+
+ {t('No feature flags were found for this event')}
+
+ )}
);
diff --git a/static/app/components/events/featureFlags/featureFlagSort.tsx b/static/app/components/events/featureFlags/featureFlagSort.tsx
index 188ffdb9b2b721..611e2d8542d0a6 100644
--- a/static/app/components/events/featureFlags/featureFlagSort.tsx
+++ b/static/app/components/events/featureFlags/featureFlagSort.tsx
@@ -31,6 +31,7 @@ export default function FeatureFlagSort({sortBy, orderBy, setOrderBy, setSortBy}
aria-label={t('Sort Flags')}
size="xs"
icon={}
+ title={t('Sort Flags')}
/>
)}
>
diff --git a/static/app/components/events/featureFlags/setupIntegrationModal.tsx b/static/app/components/events/featureFlags/setupIntegrationModal.tsx
new file mode 100644
index 00000000000000..1b921e4d9acfc0
--- /dev/null
+++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx
@@ -0,0 +1,227 @@
+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';
+import Alert from 'sentry/components/alert';
+import {Button, LinkButton} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import type {Data} from 'sentry/components/forms/types';
+import TextCopyInput from 'sentry/components/textCopyInput';
+import {IconWarning} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import OrganizationStore from 'sentry/stores/organizationStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import {space} from 'sentry/styles/space';
+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;
+ Header: (props: {children: React.ReactNode}) => ReturnType;
+ onFieldChange: (field: Field, value: T[Field]) => void;
+ state: T;
+};
+
+interface State {
+ provider: string;
+ url: string | undefined;
+}
+
+async 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;
+}
+
+export function SetupIntegrationModal({
+ Header,
+ Body,
+ Footer,
+ closeModal,
+}: ModalRenderProps) {
+ const [state, setState] = useState({
+ provider: 'LaunchDarkly',
+ url: undefined,
+ });
+ const {organization} = useLegacyStore(OrganizationStore);
+ const token = useGenerateAuthToken(state);
+
+ const handleSubmit = useCallback(() => {
+ addSuccessMessage(t('Integration set up successfully'));
+ closeModal();
+ }, [closeModal]);
+
+ const ModalHeader = useCallback(
+ ({children: headerChildren}: {children: React.ReactNode}) => {
+ return (
+
+ );
+ },
+ [Header]
+ );
+
+ const ModalFooter = useCallback(() => {
+ return (
+
+ );
+ }, [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 (
);
- }, [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',
};