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/eventFeatureFlagList.tsx b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx
index ae0c7aba42a188..c553644a127b4a 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, useEffect, 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,16 @@ export function EventFeatureFlagList({
event,
});
+ const hasFlagContext = !!event.contexts.flags;
+ const hasFlags = Boolean(hasFlagContext && event?.contexts?.flags?.values.length);
+
+ function handleSetupButtonClick() {
+ trackAnalytics('flags.setup_modal_opened', {organization});
+ openModal(modalProps => , {
+ modalCss,
+ });
+ }
+
const suspectFlagNames: Set = useMemo(() => {
return isSuspectError || isSuspectPending
? new Set()
@@ -150,37 +166,63 @@ export function EventFeatureFlagList({
[openDrawer, event, group, project, hydratedFlags, organization, sortBy, orderBy]
);
- if (!hydratedFlags.length) {
+ useEffect(() => {
+ if (hasFlags) {
+ trackAnalytics('flags.table_rendered', {
+ organization,
+ numFlags: hydratedFlags.length,
+ });
+ }
+ }, [hasFlags, hydratedFlags.length, organization]);
+
+ // 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 +245,16 @@ export function EventFeatureFlagList({
type={SectionKey.FEATURE_FLAGS}
actions={actions}
>
-
-
-
-
+ {hasFlags ? (
+
+
+
+
+ ) : (
+
+ {t('No feature flags were found for this event')}
+
+ )}
);
@@ -220,3 +268,11 @@ const ValueWrapper = styled('div')`
display: flex;
justify-content: space-between;
`;
+
+const StyledEmptyStateWarning = styled(EmptyStateWarning)`
+ border: ${p => p.theme.border} solid 1px;
+ border-radius: ${p => p.theme.borderRadius};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
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..3febc7a350e002
--- /dev/null
+++ b/static/app/components/events/featureFlags/setupIntegrationModal.tsx
@@ -0,0 +1,220 @@
+import {Fragment, useCallback, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+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 {trackAnalytics} from 'sentry/utils/analytics';
+import useApi from 'sentry/utils/useApi';
+
+export type ChildrenProps = {
+ Body: (props: {children: React.ReactNode}) => ReturnType;
+ Footer: () => ReturnType;
+ Header: (props: {children: React.ReactNode}) => ReturnType;
+ state: T;
+};
+
+interface State {
+ provider: string;
+ url: string | undefined;
+}
+
+function useGenerateAuthToken({
+ state,
+ orgSlug,
+}: {
+ orgSlug: string | undefined;
+ state: State;
+}) {
+ const api = useApi();
+ const date = new Date().toISOString();
+
+ const createToken = async () =>
+ await api.requestPromise(`/organizations/${orgSlug}/org-auth-tokens/`, {
+ method: 'POST',
+ data: {
+ name: `${state.provider} Token ${date}`,
+ },
+ });
+
+ return {createToken};
+}
+
+export function SetupIntegrationModal({
+ Header,
+ Body,
+ Footer,
+ closeModal,
+}: ModalRenderProps) {
+ const [state, setState] = useState({
+ provider: 'LaunchDarkly',
+ url: undefined,
+ });
+ const {organization} = useLegacyStore(OrganizationStore);
+ const {createToken} = useGenerateAuthToken({state, orgSlug: organization?.slug});
+
+ const handleDone = useCallback(() => {
+ addSuccessMessage(t('Integration set up successfully'));
+ closeModal();
+ }, [closeModal]);
+
+ const ModalHeader = useCallback(
+ ({children: headerChildren}: {children: React.ReactNode}) => {
+ return (
+
+ );
+ },
+ [Header]
+ );
+
+ const ModalFooter = useCallback(() => {
+ return (
+
+ );
+ }, [Footer, handleDone, state]);
+
+ const ModalBody = useCallback(
+ ({children: bodyChildren}: Parameters['Body']>[0]) => {
+ return {bodyChildren};
+ },
+ [Body]
+ );
+
+ 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 (
+
+ {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
+ />
+
+ {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)};
+`;
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(),
+};
diff --git a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx
index 612391a7dea184..ebb162119e9cae 100644
--- a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx
@@ -4,8 +4,13 @@ export type FeatureFlagEventParameters = {
numSuspectFlags: number;
numTotalFlags: number;
};
+ 'flags.setup_modal_opened': {};
'flags.sort-flags': {sortMethod: string};
+ 'flags.table_rendered': {
+ numFlags: number;
+ };
'flags.view-all-clicked': {};
+ 'flags.webhook_url_generated': {};
};
export type FeatureFlagEventKey = keyof FeatureFlagEventParameters;
@@ -14,4 +19,7 @@ export const featureFlagEventMap: Record = {
'flags.view-all-clicked': 'Clicked View All Flags',
'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 Setup Integration Modal',
+ 'flags.table_rendered': 'Flag Table Rendered',
};