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 ( + +
{t('Link to Dashboard')}
+ + + + {({hasReachedDashboardLimit, isLoading, limitMessage}) => ( +