diff --git a/static/app/actionCreators/modal.tsx b/static/app/actionCreators/modal.tsx
index 9c0b7c26b690b7..ecfcd82d0ad93d 100644
--- a/static/app/actionCreators/modal.tsx
+++ b/static/app/actionCreators/modal.tsx
@@ -11,6 +11,7 @@ import type {PrivateGamingSdkAccessModalProps} from 'sentry/components/modals/pr
import type {ReprocessEventModalOptions} from 'sentry/components/modals/reprocessEventModal';
import type {TokenRegenerationConfirmationModalProps} from 'sentry/components/modals/tokenRegenerationConfirmationModal';
import type {AddToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/addToDashboardModal';
+import type {LinkToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/linkToDashboardModal';
import type {OverwriteWidgetModalProps} from 'sentry/components/modals/widgetBuilder/overwriteWidgetModal';
import type {WidgetViewerModalOptions} from 'sentry/components/modals/widgetViewerModal';
import type {ConsoleModalProps} from 'sentry/components/onboarding/consoleModal';
@@ -278,6 +279,17 @@ export async function openAddToDashboardModal(options: AddToDashboardModalProps)
});
}
+export async function openLinkToDashboardModal(options: LinkToDashboardModalProps) {
+ const {LinkToDashboardModal, modalCss} = await import(
+ 'sentry/components/modals/widgetBuilder/linkToDashboardModal'
+ );
+
+ openModal(deps => , {
+ closeEvents: 'escape-key',
+ modalCss,
+ });
+}
+
export async function openImportDashboardFromFileModal(
options: ImportDashboardFromFileModalProps
) {
diff --git a/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx b/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx
new file mode 100644
index 00000000000000..53309fd2ecfaa5
--- /dev/null
+++ b/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx
@@ -0,0 +1,184 @@
+import {Fragment, useCallback, useEffect, useState, type ReactNode} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {fetchDashboard, fetchDashboards} from 'sentry/actionCreators/dashboards';
+import type {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/core/button';
+import {ButtonBar} from 'sentry/components/core/button/buttonBar';
+import {Select} from 'sentry/components/core/select';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {SelectValue} from 'sentry/types/core';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useParams} from 'sentry/utils/useParams';
+import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
+import type {DashboardDetails, DashboardListItem} from 'sentry/views/dashboards/types';
+import {MAX_WIDGETS} from 'sentry/views/dashboards/types';
+import {NEW_DASHBOARD_ID} from 'sentry/views/dashboards/widgetBuilder/utils';
+
+export type LinkToDashboardModalProps = {
+ source?: string; // TODO: perhpas make this an enum
+};
+
+type Props = ModalRenderProps & LinkToDashboardModalProps;
+
+const SELECT_DASHBOARD_MESSAGE = t('Select a dashboard');
+
+export function LinkToDashboardModal({Header, Body, Footer, closeModal}: Props) {
+ const api = useApi();
+ const organization = useOrganization();
+ const [dashboards, setDashboards] = useState(null);
+ const [_, setSelectedDashboard] = useState(null);
+ const [selectedDashboardId, setSelectedDashboardId] = useState(null);
+
+ const {dashboardId: currentDashboardId} = useParams<{dashboardId: string}>();
+
+ useEffect(() => {
+ // Track mounted state so we dont call setState on unmounted components
+ let unmounted = false;
+
+ fetchDashboards(api, organization.slug).then(response => {
+ // If component has unmounted, dont set state
+ if (unmounted) {
+ return;
+ }
+
+ setDashboards(response);
+ });
+
+ return () => {
+ unmounted = true;
+ };
+ }, [api, organization.slug]);
+
+ useEffect(() => {
+ // Track mounted state so we dont call setState on unmounted components
+ let unmounted = false;
+
+ if (selectedDashboardId === NEW_DASHBOARD_ID || selectedDashboardId === null) {
+ setSelectedDashboard(null);
+ } else {
+ fetchDashboard(api, organization.slug, selectedDashboardId).then(response => {
+ // If component has unmounted, dont set state
+ if (unmounted) {
+ return;
+ }
+
+ setSelectedDashboard(response);
+ });
+ }
+
+ return () => {
+ unmounted = true;
+ };
+ }, [api, organization.slug, selectedDashboardId]);
+
+ const canSubmit = selectedDashboardId !== null;
+
+ const getOptions = useCallback(
+ (
+ hasReachedDashboardLimit: boolean,
+ isLoading: boolean,
+ limitMessage: ReactNode | null
+ ) => {
+ if (dashboards === null) {
+ return null;
+ }
+
+ return [
+ {
+ label: t('+ Create New Dashboard'),
+ value: 'new',
+ disabled: hasReachedDashboardLimit || isLoading,
+ tooltip: hasReachedDashboardLimit ? limitMessage : undefined,
+ tooltipOptions: {position: 'right', isHoverable: true},
+ },
+ ...dashboards
+ .filter(dashboard =>
+ // if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options
+ currentDashboardId ? dashboard.id !== currentDashboardId : true
+ )
+ .map(({title, id, widgetDisplay}) => ({
+ label: title,
+ value: id,
+ disabled: widgetDisplay.length >= MAX_WIDGETS,
+ tooltip:
+ widgetDisplay.length >= MAX_WIDGETS &&
+ tct('Max widgets ([maxWidgets]) per dashboard reached.', {
+ maxWidgets: MAX_WIDGETS,
+ }),
+ tooltipOptions: {position: 'right'},
+ })),
+ ].filter(Boolean) as Array>;
+ },
+ [currentDashboardId, dashboards]
+ );
+
+ function linkToDashboard() {
+ // TODO: Update the local state of the widget to include the links
+ // When the user clicks `save widget` we should update the dashboard widget link on the backend
+ closeModal();
+ }
+
+ return (
+
+
+
+
+
+ {({hasReachedDashboardLimit, isLoading, limitMessage}) => (
+
+
+
+
+
+ );
+}
+
+const Wrapper = styled('div')`
+ margin-bottom: ${space(2)};
+`;
+
+const StyledButtonBar = styled(ButtonBar)`
+ @media (max-width: ${props => props.theme.breakpoints.sm}) {
+ grid-template-rows: repeat(2, 1fr);
+ gap: ${space(1.5)};
+ width: 100%;
+
+ > button {
+ width: 100%;
+ }
+ }
+`;
+
+export const modalCss = css`
+ max-width: 700px;
+ margin: 70px auto;
+`;
diff --git a/static/app/views/dashboards/createLimitWrapper.tsx b/static/app/views/dashboards/createLimitWrapper.tsx
index 82ed78fd4c7c53..2bbaf0fcf60082 100644
--- a/static/app/views/dashboards/createLimitWrapper.tsx
+++ b/static/app/views/dashboards/createLimitWrapper.tsx
@@ -1,5 +1,14 @@
+import type {ReactNode} from 'react';
+
import HookOrDefault from 'sentry/components/hookOrDefault';
+type DashboardCreateLimitWrapperResult = {
+ dashboardsLimit: number;
+ hasReachedDashboardLimit: boolean;
+ isLoading: boolean;
+ limitMessage: ReactNode | null;
+};
+
export const DashboardCreateLimitWrapper = HookOrDefault({
hookName: 'component:dashboards-limit-provider',
defaultComponent: ({children}) =>
@@ -9,6 +18,6 @@ export const DashboardCreateLimitWrapper = HookOrDefault({
dashboardsLimit: 0,
isLoading: false,
limitMessage: null,
- })
+ } satisfies DashboardCreateLimitWrapperResult)
: children,
});
diff --git a/static/app/views/dashboards/hooks/useHasDrillDownFlows.tsx b/static/app/views/dashboards/hooks/useHasDrillDownFlows.tsx
new file mode 100644
index 00000000000000..d4290e708f921b
--- /dev/null
+++ b/static/app/views/dashboards/hooks/useHasDrillDownFlows.tsx
@@ -0,0 +1,6 @@
+import useOrganization from 'sentry/utils/useOrganization';
+
+export function useHasDrillDownFlows() {
+ const organization = useOrganization();
+ return organization.features.includes('dashboards-drilldown-flow');
+}
diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
index 0eb644c12d35cd..94ec5b182c17cc 100644
--- a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx
@@ -5,6 +5,7 @@ import {css} from '@emotion/react';
import styled from '@emotion/styled';
import cloneDeep from 'lodash/cloneDeep';
+import {openLinkToDashboardModal} from 'sentry/actionCreators/modal';
import {Tag, type TagProps} from 'sentry/components/core/badge/tag';
import {Button} from 'sentry/components/core/button';
import {CompactSelect} from 'sentry/components/core/compactSelect';
@@ -13,7 +14,7 @@ import {Input} from 'sentry/components/core/input';
import {Radio} from 'sentry/components/core/radio';
import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
import FieldGroup from 'sentry/components/forms/fieldGroup';
-import {IconDelete} from 'sentry/icons';
+import {IconDelete, IconLink} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {SelectValue} from 'sentry/types/core';
@@ -34,6 +35,7 @@ import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
import useOrganization from 'sentry/utils/useOrganization';
import useTags from 'sentry/utils/useTags';
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
+import {useHasDrillDownFlows} from 'sentry/views/dashboards/hooks/useHasDrillDownFlows';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
import SortableVisualizeFieldWrapper from 'sentry/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper';
@@ -272,6 +274,7 @@ function Visualize({error, setError}: VisualizeProps) {
state.displayType !== DisplayType.TABLE &&
state.displayType !== DisplayType.BIG_NUMBER;
const isBigNumberWidget = state.displayType === DisplayType.BIG_NUMBER;
+ const isTableWidget = state.displayType === DisplayType.TABLE;
const {tags: numericSpanTags} = useTraceItemTags('number');
const {tags: stringSpanTags} = useTraceItemTags('string');
@@ -372,6 +375,7 @@ function Visualize({error, setError}: VisualizeProps) {
const hasExploreEquations = organization.features.includes(
'visibility-explore-equations'
);
+ const hasDrillDownFlows = useHasDrillDownFlows();
return (
@@ -776,7 +780,20 @@ function Visualize({error, setError}: VisualizeProps) {
}}
/>
)}
- }
+ aria-label={t('Link field')}
+ size="zero"
+ onClick={() => {
+ openLinkToDashboardModal({
+ source,
+ });
+ }}
+ />
+ )}
+ }
size="zero"
@@ -1034,8 +1051,6 @@ export const FieldRow = styled('div')`
min-width: 0;
`;
-export const StyledDeleteButton = styled(Button)``;
-
export const FieldExtras = styled('div')<{isChartWidget: boolean}>`
display: flex;
flex-direction: row;
diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
index 850834d429ffe6..eff9563e589bc2 100644
--- a/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx
@@ -21,7 +21,6 @@ import {
ParameterRefinements,
PrimarySelectRow,
StyledArithmeticInput,
- StyledDeleteButton,
} from 'sentry/views/dashboards/widgetBuilder/components/visualize/index';
import {ColumnCompactSelect} from 'sentry/views/dashboards/widgetBuilder/components/visualize/selectRow';
import {FieldValueKind, type FieldValue} from 'sentry/views/discover/table/types';
@@ -204,7 +203,7 @@ function VisualizeGhostField({
onChange={() => {}}
/>
)}
- }
size="zero"