Skip to content

Commit 1b0021d

Browse files
authored
feat(dashboards): add create link button to table edit (#101204)
Some initial work for drill down flows. This adds the create link button when editing a table and a corresponding modal. This stuff doesn't do anything yet as i'm working on some backend changes at the same time. And a lot will change between now and the final, putting this PR up to make it easier to review. <img width="608" height="336" alt="image" src="https://github.com/user-attachments/assets/0cb2ccb5-fa5a-48e5-9833-1d37ae9c4b20" /> <img width="702" height="335" alt="image" src="https://github.com/user-attachments/assets/5f2576e1-c5b6-4771-a364-82aff527d527" />
1 parent 21ba6b9 commit 1b0021d

File tree

6 files changed

+232
-7
lines changed

6 files changed

+232
-7
lines changed

static/app/actionCreators/modal.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {PrivateGamingSdkAccessModalProps} from 'sentry/components/modals/pr
1111
import type {ReprocessEventModalOptions} from 'sentry/components/modals/reprocessEventModal';
1212
import type {TokenRegenerationConfirmationModalProps} from 'sentry/components/modals/tokenRegenerationConfirmationModal';
1313
import type {AddToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/addToDashboardModal';
14+
import type {LinkToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/linkToDashboardModal';
1415
import type {OverwriteWidgetModalProps} from 'sentry/components/modals/widgetBuilder/overwriteWidgetModal';
1516
import type {WidgetViewerModalOptions} from 'sentry/components/modals/widgetViewerModal';
1617
import type {ConsoleModalProps} from 'sentry/components/onboarding/consoleModal';
@@ -278,6 +279,17 @@ export async function openAddToDashboardModal(options: AddToDashboardModalProps)
278279
});
279280
}
280281

282+
export async function openLinkToDashboardModal(options: LinkToDashboardModalProps) {
283+
const {LinkToDashboardModal, modalCss} = await import(
284+
'sentry/components/modals/widgetBuilder/linkToDashboardModal'
285+
);
286+
287+
openModal(deps => <LinkToDashboardModal {...deps} {...options} />, {
288+
closeEvents: 'escape-key',
289+
modalCss,
290+
});
291+
}
292+
281293
export async function openImportDashboardFromFileModal(
282294
options: ImportDashboardFromFileModalProps
283295
) {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {Fragment, useCallback, useEffect, useState, type ReactNode} from 'react';
2+
import {css} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
5+
import {fetchDashboard, fetchDashboards} from 'sentry/actionCreators/dashboards';
6+
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
7+
import {Button} from 'sentry/components/core/button';
8+
import {ButtonBar} from 'sentry/components/core/button/buttonBar';
9+
import {Select} from 'sentry/components/core/select';
10+
import {t, tct} from 'sentry/locale';
11+
import {space} from 'sentry/styles/space';
12+
import type {SelectValue} from 'sentry/types/core';
13+
import useApi from 'sentry/utils/useApi';
14+
import useOrganization from 'sentry/utils/useOrganization';
15+
import {useParams} from 'sentry/utils/useParams';
16+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
17+
import type {DashboardDetails, DashboardListItem} from 'sentry/views/dashboards/types';
18+
import {MAX_WIDGETS} from 'sentry/views/dashboards/types';
19+
import {NEW_DASHBOARD_ID} from 'sentry/views/dashboards/widgetBuilder/utils';
20+
21+
export type LinkToDashboardModalProps = {
22+
source?: string; // TODO: perhpas make this an enum
23+
};
24+
25+
type Props = ModalRenderProps & LinkToDashboardModalProps;
26+
27+
const SELECT_DASHBOARD_MESSAGE = t('Select a dashboard');
28+
29+
export function LinkToDashboardModal({Header, Body, Footer, closeModal}: Props) {
30+
const api = useApi();
31+
const organization = useOrganization();
32+
const [dashboards, setDashboards] = useState<DashboardListItem[] | null>(null);
33+
const [_, setSelectedDashboard] = useState<DashboardDetails | null>(null);
34+
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(null);
35+
36+
const {dashboardId: currentDashboardId} = useParams<{dashboardId: string}>();
37+
38+
useEffect(() => {
39+
// Track mounted state so we dont call setState on unmounted components
40+
let unmounted = false;
41+
42+
fetchDashboards(api, organization.slug).then(response => {
43+
// If component has unmounted, dont set state
44+
if (unmounted) {
45+
return;
46+
}
47+
48+
setDashboards(response);
49+
});
50+
51+
return () => {
52+
unmounted = true;
53+
};
54+
}, [api, organization.slug]);
55+
56+
useEffect(() => {
57+
// Track mounted state so we dont call setState on unmounted components
58+
let unmounted = false;
59+
60+
if (selectedDashboardId === NEW_DASHBOARD_ID || selectedDashboardId === null) {
61+
setSelectedDashboard(null);
62+
} else {
63+
fetchDashboard(api, organization.slug, selectedDashboardId).then(response => {
64+
// If component has unmounted, dont set state
65+
if (unmounted) {
66+
return;
67+
}
68+
69+
setSelectedDashboard(response);
70+
});
71+
}
72+
73+
return () => {
74+
unmounted = true;
75+
};
76+
}, [api, organization.slug, selectedDashboardId]);
77+
78+
const canSubmit = selectedDashboardId !== null;
79+
80+
const getOptions = useCallback(
81+
(
82+
hasReachedDashboardLimit: boolean,
83+
isLoading: boolean,
84+
limitMessage: ReactNode | null
85+
) => {
86+
if (dashboards === null) {
87+
return null;
88+
}
89+
90+
return [
91+
{
92+
label: t('+ Create New Dashboard'),
93+
value: 'new',
94+
disabled: hasReachedDashboardLimit || isLoading,
95+
tooltip: hasReachedDashboardLimit ? limitMessage : undefined,
96+
tooltipOptions: {position: 'right', isHoverable: true},
97+
},
98+
...dashboards
99+
.filter(dashboard =>
100+
// if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options
101+
currentDashboardId ? dashboard.id !== currentDashboardId : true
102+
)
103+
.map(({title, id, widgetDisplay}) => ({
104+
label: title,
105+
value: id,
106+
disabled: widgetDisplay.length >= MAX_WIDGETS,
107+
tooltip:
108+
widgetDisplay.length >= MAX_WIDGETS &&
109+
tct('Max widgets ([maxWidgets]) per dashboard reached.', {
110+
maxWidgets: MAX_WIDGETS,
111+
}),
112+
tooltipOptions: {position: 'right'},
113+
})),
114+
].filter(Boolean) as Array<SelectValue<string>>;
115+
},
116+
[currentDashboardId, dashboards]
117+
);
118+
119+
function linkToDashboard() {
120+
// TODO: Update the local state of the widget to include the links
121+
// When the user clicks `save widget` we should update the dashboard widget link on the backend
122+
closeModal();
123+
}
124+
125+
return (
126+
<Fragment>
127+
<Header closeButton>{t('Link to Dashboard')}</Header>
128+
<Body>
129+
<Wrapper>
130+
<DashboardCreateLimitWrapper>
131+
{({hasReachedDashboardLimit, isLoading, limitMessage}) => (
132+
<Select
133+
disabled={dashboards === null}
134+
name="dashboard"
135+
placeholder={t('Select Dashboard')}
136+
value={selectedDashboardId}
137+
options={getOptions(hasReachedDashboardLimit, isLoading, limitMessage)}
138+
onChange={(option: SelectValue<string>) => {
139+
if (option.disabled) {
140+
return;
141+
}
142+
setSelectedDashboardId(option.value);
143+
}}
144+
/>
145+
)}
146+
</DashboardCreateLimitWrapper>
147+
</Wrapper>
148+
</Body>
149+
<Footer>
150+
<StyledButtonBar gap="lg">
151+
<Button
152+
disabled={!canSubmit}
153+
title={canSubmit ? undefined : SELECT_DASHBOARD_MESSAGE}
154+
onClick={() => linkToDashboard()}
155+
aria-label={t('Link to dashboard')}
156+
>
157+
{t('Link to dashboard')}
158+
</Button>
159+
</StyledButtonBar>
160+
</Footer>
161+
</Fragment>
162+
);
163+
}
164+
165+
const Wrapper = styled('div')`
166+
margin-bottom: ${space(2)};
167+
`;
168+
169+
const StyledButtonBar = styled(ButtonBar)`
170+
@media (max-width: ${props => props.theme.breakpoints.sm}) {
171+
grid-template-rows: repeat(2, 1fr);
172+
gap: ${space(1.5)};
173+
width: 100%;
174+
175+
> button {
176+
width: 100%;
177+
}
178+
}
179+
`;
180+
181+
export const modalCss = css`
182+
max-width: 700px;
183+
margin: 70px auto;
184+
`;

static/app/views/dashboards/createLimitWrapper.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
import type {ReactNode} from 'react';
2+
13
import HookOrDefault from 'sentry/components/hookOrDefault';
24

5+
type DashboardCreateLimitWrapperResult = {
6+
dashboardsLimit: number;
7+
hasReachedDashboardLimit: boolean;
8+
isLoading: boolean;
9+
limitMessage: ReactNode | null;
10+
};
11+
312
export const DashboardCreateLimitWrapper = HookOrDefault({
413
hookName: 'component:dashboards-limit-provider',
514
defaultComponent: ({children}) =>
@@ -9,6 +18,6 @@ export const DashboardCreateLimitWrapper = HookOrDefault({
918
dashboardsLimit: 0,
1019
isLoading: false,
1120
limitMessage: null,
12-
})
21+
} satisfies DashboardCreateLimitWrapperResult)
1322
: children,
1423
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import useOrganization from 'sentry/utils/useOrganization';
2+
3+
export function useHasDrillDownFlows() {
4+
const organization = useOrganization();
5+
return organization.features.includes('dashboards-drilldown-flow');
6+
}

static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {css} from '@emotion/react';
55
import styled from '@emotion/styled';
66
import cloneDeep from 'lodash/cloneDeep';
77

8+
import {openLinkToDashboardModal} from 'sentry/actionCreators/modal';
89
import {Tag, type TagProps} from 'sentry/components/core/badge/tag';
910
import {Button} from 'sentry/components/core/button';
1011
import {CompactSelect} from 'sentry/components/core/compactSelect';
@@ -13,7 +14,7 @@ import {Input} from 'sentry/components/core/input';
1314
import {Radio} from 'sentry/components/core/radio';
1415
import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
1516
import FieldGroup from 'sentry/components/forms/fieldGroup';
16-
import {IconDelete} from 'sentry/icons';
17+
import {IconDelete, IconLink} from 'sentry/icons';
1718
import {t, tct} from 'sentry/locale';
1819
import {space} from 'sentry/styles/space';
1920
import type {SelectValue} from 'sentry/types/core';
@@ -34,6 +35,7 @@ import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
3435
import useOrganization from 'sentry/utils/useOrganization';
3536
import useTags from 'sentry/utils/useTags';
3637
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
38+
import {useHasDrillDownFlows} from 'sentry/views/dashboards/hooks/useHasDrillDownFlows';
3739
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
3840
import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
3941
import SortableVisualizeFieldWrapper from 'sentry/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper';
@@ -272,6 +274,7 @@ function Visualize({error, setError}: VisualizeProps) {
272274
state.displayType !== DisplayType.TABLE &&
273275
state.displayType !== DisplayType.BIG_NUMBER;
274276
const isBigNumberWidget = state.displayType === DisplayType.BIG_NUMBER;
277+
const isTableWidget = state.displayType === DisplayType.TABLE;
275278
const {tags: numericSpanTags} = useTraceItemTags('number');
276279
const {tags: stringSpanTags} = useTraceItemTags('string');
277280

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

376380
return (
377381
<Fragment>
@@ -776,7 +780,20 @@ function Visualize({error, setError}: VisualizeProps) {
776780
}}
777781
/>
778782
)}
779-
<StyledDeleteButton
783+
{hasDrillDownFlows && isTableWidget && (
784+
<Button
785+
borderless
786+
icon={<IconLink />}
787+
aria-label={t('Link field')}
788+
size="zero"
789+
onClick={() => {
790+
openLinkToDashboardModal({
791+
source,
792+
});
793+
}}
794+
/>
795+
)}
796+
<Button
780797
borderless
781798
icon={<IconDelete />}
782799
size="zero"
@@ -1034,8 +1051,6 @@ export const FieldRow = styled('div')`
10341051
min-width: 0;
10351052
`;
10361053

1037-
export const StyledDeleteButton = styled(Button)``;
1038-
10391054
export const FieldExtras = styled('div')<{isChartWidget: boolean}>`
10401055
display: flex;
10411056
flex-direction: row;

static/app/views/dashboards/widgetBuilder/components/visualize/visualizeGhostField.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
ParameterRefinements,
2222
PrimarySelectRow,
2323
StyledArithmeticInput,
24-
StyledDeleteButton,
2524
} from 'sentry/views/dashboards/widgetBuilder/components/visualize/index';
2625
import {ColumnCompactSelect} from 'sentry/views/dashboards/widgetBuilder/components/visualize/selectRow';
2726
import {FieldValueKind, type FieldValue} from 'sentry/views/discover/table/types';
@@ -204,7 +203,7 @@ function VisualizeGhostField({
204203
onChange={() => {}}
205204
/>
206205
)}
207-
<StyledDeleteButton
206+
<Button
208207
borderless
209208
icon={<IconDelete />}
210209
size="zero"

0 commit comments

Comments
 (0)