Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions static/app/actionCreators/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 => <LinkToDashboardModal {...deps} {...options} />, {
closeEvents: 'escape-key',
modalCss,
});
}

export async function openImportDashboardFromFileModal(
options: ImportDashboardFromFileModalProps
) {
Expand Down
208 changes: 208 additions & 0 deletions static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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 {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
import {useParams} from 'sentry/utils/useParams';
import {
DashboardCreateLimitWrapper,
type DashboardCreateLimitWrapperResult,
} from 'sentry/views/dashboards/createLimitWrapper';
import type {DashboardDetails, DashboardListItem} from 'sentry/views/dashboards/types';
import {MAX_WIDGETS} from 'sentry/views/dashboards/types';
import {getSavedPageFilters} from 'sentry/views/dashboards/utils';
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 navigate = useNavigate();
const organization = useOrganization();
const [dashboards, setDashboards] = useState<DashboardListItem[] | null>(null);
const [selectedDashboard, setSelectedDashboard] = useState<DashboardDetails | null>(
null
);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(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<SelectValue<string>>;
},
[currentDashboardId, dashboards]
);

function goToDashboard() {
const dashboardsPath =
selectedDashboardId === NEW_DASHBOARD_ID
? `/organizations/${organization.slug}/dashboards/new/`
: `/organizations/${organization.slug}/dashboard/${selectedDashboardId}/`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Dashboard Creation Path Inconsistent

The "Create New Dashboard" option uses the string value 'new', but the modal's logic checks against the NEW_DASHBOARD_ID constant. This inconsistency means the new dashboard creation path won't be taken, preventing users from creating new dashboards through this modal.

Fix in Cursor Fix in Web


const pathname = dashboardsPath;

navigate({
pathname,
query: {
...(selectedDashboard ? getSavedPageFilters(selectedDashboard) : {}),
},
});
closeModal();
}

return (
<Fragment>
<Header closeButton>{t('Link to Dashboard')}</Header>
<Body>
<Wrapper>
<DashboardCreateLimitWrapper>
{({

Check failure on line 150 in static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx

View workflow job for this annotation

GitHub Actions / typescript

Type '({ hasReachedDashboardLimit, isLoading, limitMessage, }: DashboardCreateLimitWrapperResult) => EmotionJSX.Element' is not assignable to type 'ReactNode | ((limitData: { dashboardsLimit: number; hasReachedDashboardLimit: boolean; isLoading: boolean; limitMessage: ReactNode; }) => ReactNode)'.
hasReachedDashboardLimit,
isLoading,
limitMessage,
}: DashboardCreateLimitWrapperResult) => (
<Select
disabled={dashboards === null}
name="dashboard"
placeholder={t('Select Dashboard')}
value={selectedDashboardId}
options={getOptions(hasReachedDashboardLimit, isLoading, limitMessage)}
onChange={(option: SelectValue<string>) => {
if (option.disabled) {
return;
}
setSelectedDashboardId(option.value);
}}
/>
)}
</DashboardCreateLimitWrapper>
</Wrapper>
</Body>

<Footer>
<StyledButtonBar gap="lg">
<Button
disabled={!canSubmit}
title={canSubmit ? undefined : SELECT_DASHBOARD_MESSAGE}
onClick={() => goToDashboard()}
aria-label={t('Link to dashboard')}
>
{t('Link to dashboard')}
</Button>
</StyledButtonBar>
</Footer>
</Fragment>
);
}

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;
`;
9 changes: 8 additions & 1 deletion static/app/views/dashboards/createLimitWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import HookOrDefault from 'sentry/components/hookOrDefault';

export type DashboardCreateLimitWrapperResult = {
dashboardsLimit: number;
hasReachedDashboardLimit: boolean;
isLoading: boolean;
limitMessage: string | null;
};

export const DashboardCreateLimitWrapper = HookOrDefault({
hookName: 'component:dashboards-limit-provider',
defaultComponent: ({children}) =>
Expand All @@ -9,6 +16,6 @@ export const DashboardCreateLimitWrapper = HookOrDefault({
dashboardsLimit: 0,
isLoading: false,
limitMessage: null,
})
} satisfies DashboardCreateLimitWrapperResult)
: children,
});
6 changes: 6 additions & 0 deletions static/app/views/dashboards/hooks/useHasDrillDownFlows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import useOrganization from 'sentry/utils/useOrganization';

export function useHasDrillDownFlows() {
const organization = useOrganization();
return organization.features.includes('dashboards-drilldown-flow');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -372,6 +375,7 @@ function Visualize({error, setError}: VisualizeProps) {
const hasExploreEquations = organization.features.includes(
'visibility-explore-equations'
);
const hasDrillDownFlows = useHasDrillDownFlows();

return (
<Fragment>
Expand Down Expand Up @@ -776,7 +780,20 @@ function Visualize({error, setError}: VisualizeProps) {
}}
/>
)}
<StyledDeleteButton
{hasDrillDownFlows && isTableWidget && (
<Button
borderless
icon={<IconLink />}
aria-label={t('Link field')}
size="zero"
onClick={() => {
openLinkToDashboardModal({
source,
});
}}
/>
)}
<Button
borderless
icon={<IconDelete />}
size="zero"
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -204,7 +203,7 @@ function VisualizeGhostField({
onChange={() => {}}
/>
)}
<StyledDeleteButton
<Button
borderless
icon={<IconDelete />}
size="zero"
Expand Down
Loading