Skip to content

Commit 09c0eee

Browse files
authored
feat(dashboard-limits): Disable Add to Dashboard flows if at max (#97722)
Disables the "Create New Dashboard" option in Add to Dashboard flows if the limit has been reached (indicated by the `DashboardCreateLimitWrapper` If we've hit the limit, disable the option and display the tooltip to the right of the text. Note, the message in the following screenshot is a forced example on the `sentry` org, that has no limit (which is why it says -1).
1 parent d54a521 commit 09c0eee

File tree

3 files changed

+123
-44
lines changed

3 files changed

+123
-44
lines changed

static/app/components/modals/widgetBuilder/addToDashboardModal.spec.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import selectEvent from 'sentry-test/selectEvent';
66

77
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
88
import AddToDashboardModal from 'sentry/components/modals/widgetBuilder/addToDashboardModal';
9+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
910
import type {DashboardDetails, DashboardListItem} from 'sentry/views/dashboards/types';
1011
import {
1112
DashboardWidgetSource,
@@ -19,6 +20,9 @@ const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</d
1920
jest.mock('sentry/components/lazyRender', () => ({
2021
LazyRender: ({children}: {children: React.ReactNode}) => children,
2122
}));
23+
jest.mock('sentry/views/dashboards/createLimitWrapper');
24+
25+
const mockDashboardCreateLimitWrapper = jest.mocked(DashboardCreateLimitWrapper);
2226

2327
describe('add to dashboard modal', () => {
2428
let eventsStatsMock!: jest.Mock;
@@ -77,6 +81,18 @@ describe('add to dashboard modal', () => {
7781
beforeEach(() => {
7882
initialData = initializeOrg();
7983

84+
// Default behaviour for dashboard create limit wrapper
85+
mockDashboardCreateLimitWrapper.mockImplementation(({children}: {children: any}) =>
86+
typeof children === 'function'
87+
? children({
88+
hasReachedDashboardLimit: false,
89+
dashboardsLimit: 0,
90+
isLoading: false,
91+
limitMessage: null,
92+
})
93+
: children
94+
);
95+
8096
MockApiClient.addMockResponse({
8197
url: '/organizations/org-slug/dashboards/',
8298
body: [
@@ -631,4 +647,55 @@ describe('add to dashboard modal', () => {
631647
expect(screen.getByText('Other Dashboard')).toBeInTheDocument();
632648
expect(screen.queryByText('Test Dashboard')).not.toBeInTheDocument();
633649
});
650+
651+
it('disables "Create New Dashboard" option when dashboard limit is reached', async () => {
652+
// Override the mock for this specific test
653+
mockDashboardCreateLimitWrapper.mockImplementation(({children}: {children: any}) =>
654+
typeof children === 'function'
655+
? children({
656+
hasReachedDashboardLimit: true,
657+
dashboardsLimit: 5,
658+
isLoading: false,
659+
limitMessage:
660+
'You have reached the dashboard limit (5) for your plan. Upgrade to create more dashboards.',
661+
})
662+
: children
663+
);
664+
665+
render(
666+
<AddToDashboardModal
667+
Header={stubEl}
668+
Footer={stubEl as ModalRenderProps['Footer']}
669+
Body={stubEl as ModalRenderProps['Body']}
670+
CloseButton={stubEl}
671+
closeModal={() => undefined}
672+
organization={initialData.organization}
673+
widget={widget}
674+
selection={defaultSelection}
675+
router={initialData.router}
676+
location={LocationFixture()}
677+
/>
678+
);
679+
680+
await waitFor(() => {
681+
expect(screen.getByText('Select Dashboard')).toBeEnabled();
682+
});
683+
684+
// Open the dropdown to see the options
685+
await selectEvent.openMenu(screen.getByText('Select Dashboard'));
686+
687+
// Check that "Create New Dashboard" option exists but is disabled
688+
const createNewOption = await screen.findByRole('menuitemradio', {
689+
name: '+ Create New Dashboard',
690+
});
691+
expect(createNewOption).toBeInTheDocument();
692+
expect(createNewOption).toHaveAttribute('aria-disabled', 'true');
693+
694+
await userEvent.hover(screen.getByText('+ Create New Dashboard'));
695+
expect(
696+
await screen.findByText(
697+
'You have reached the dashboard limit (5) for your plan. Upgrade to create more dashboards.'
698+
)
699+
).toBeInTheDocument();
700+
});
634701
});

static/app/components/modals/widgetBuilder/addToDashboardModal.tsx

Lines changed: 56 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useEffect, useMemo, useState} from 'react';
1+
import {Fragment, useCallback, useEffect, useState, type ReactNode} from 'react';
22
import {css} from '@emotion/react';
33
import styled from '@emotion/styled';
44
import type {Location} from 'history';
@@ -27,6 +27,7 @@ import normalizeUrl from 'sentry/utils/url/normalizeUrl';
2727
import useApi from 'sentry/utils/useApi';
2828
import {useNavigate} from 'sentry/utils/useNavigate';
2929
import {useParams} from 'sentry/utils/useParams';
30+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
3031
import {IndexedEventsSelectionAlert} from 'sentry/views/dashboards/indexedEventsSelectionAlert';
3132
import type {
3233
DashboardDetails,
@@ -68,7 +69,6 @@ export type AddToDashboardModalProps = {
6869
selection: PageFilters;
6970
widget: Widget;
7071
actions?: AddToDashboardModalActions[];
71-
allowCreateNewDashboard?: boolean;
7272
source?: DashboardWidgetSource;
7373
};
7474

@@ -101,7 +101,6 @@ function AddToDashboardModal({
101101
selection,
102102
widget,
103103
actions = DEFAULT_ACTIONS,
104-
allowCreateNewDashboard = true,
105104
source,
106105
}: Props) {
107106
const api = useApi();
@@ -249,34 +248,44 @@ function AddToDashboardModal({
249248

250249
const canSubmit = selectedDashboardId !== null;
251250

252-
const options = useMemo(() => {
253-
if (dashboards === null) {
254-
return null;
255-
}
251+
const getOptions = useCallback(
252+
(
253+
hasReachedDashboardLimit: boolean,
254+
isLoading: boolean,
255+
limitMessage: ReactNode | null
256+
) => {
257+
if (dashboards === null) {
258+
return null;
259+
}
256260

257-
return [
258-
allowCreateNewDashboard && {
259-
label: t('+ Create New Dashboard'),
260-
value: 'new',
261-
},
262-
...dashboards
263-
.filter(dashboard =>
264-
// if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options
265-
currentDashboardId ? dashboard.id !== currentDashboardId : true
266-
)
267-
.map(({title, id, widgetDisplay}) => ({
268-
label: title,
269-
value: id,
270-
disabled: widgetDisplay.length >= MAX_WIDGETS,
271-
tooltip:
272-
widgetDisplay.length >= MAX_WIDGETS &&
273-
tct('Max widgets ([maxWidgets]) per dashboard reached.', {
274-
maxWidgets: MAX_WIDGETS,
275-
}),
261+
return [
262+
{
263+
label: t('+ Create New Dashboard'),
264+
value: 'new',
265+
disabled: hasReachedDashboardLimit || isLoading,
266+
tooltip: hasReachedDashboardLimit ? limitMessage : undefined,
276267
tooltipOptions: {position: 'right'},
277-
})),
278-
].filter(Boolean) as Array<SelectValue<string>>;
279-
}, [allowCreateNewDashboard, currentDashboardId, dashboards]);
268+
},
269+
...dashboards
270+
.filter(dashboard =>
271+
// if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options
272+
currentDashboardId ? dashboard.id !== currentDashboardId : true
273+
)
274+
.map(({title, id, widgetDisplay}) => ({
275+
label: title,
276+
value: id,
277+
disabled: widgetDisplay.length >= MAX_WIDGETS,
278+
tooltip:
279+
widgetDisplay.length >= MAX_WIDGETS &&
280+
tct('Max widgets ([maxWidgets]) per dashboard reached.', {
281+
maxWidgets: MAX_WIDGETS,
282+
}),
283+
tooltipOptions: {position: 'right'},
284+
})),
285+
].filter(Boolean) as Array<SelectValue<string>>;
286+
},
287+
[currentDashboardId, dashboards]
288+
);
280289

281290
const widgetLegendState = new WidgetLegendSelectionState({
282291
location,
@@ -308,20 +317,24 @@ function AddToDashboardModal({
308317
</Header>
309318
<Body>
310319
<Wrapper>
311-
<Select
312-
disabled={dashboards === null}
313-
menuPlacement="auto"
314-
name="dashboard"
315-
placeholder={t('Select Dashboard')}
316-
value={selectedDashboardId}
317-
options={options}
318-
onChange={(option: SelectValue<string>) => {
319-
if (option.disabled) {
320-
return;
321-
}
322-
setSelectedDashboardId(option.value);
323-
}}
324-
/>
320+
<DashboardCreateLimitWrapper>
321+
{({hasReachedDashboardLimit, isLoading, limitMessage}) => (
322+
<Select
323+
disabled={dashboards === null}
324+
menuPlacement="auto"
325+
name="dashboard"
326+
placeholder={t('Select Dashboard')}
327+
value={selectedDashboardId}
328+
options={getOptions(hasReachedDashboardLimit, isLoading, limitMessage)}
329+
onChange={(option: SelectValue<string>) => {
330+
if (option.disabled) {
331+
return;
332+
}
333+
setSelectedDashboardId(option.value);
334+
}}
335+
/>
336+
)}
337+
</DashboardCreateLimitWrapper>
325338
</Wrapper>
326339
<Wrapper>
327340
<SectionHeader title={t('Widget Name')} optional />

static/app/views/dashboards/widgetCard/widgetCardContextMenu.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,6 @@ export function getMenuOptions(
418418
layout: undefined,
419419
},
420420
actions: ['add-and-stay-on-current-page', 'open-in-widget-builder'],
421-
allowCreateNewDashboard: true,
422421
source: DashboardWidgetSource.DASHBOARDS,
423422
});
424423
},

0 commit comments

Comments
 (0)