Skip to content

Commit ff641e8

Browse files
authored
feat(dashboard-limits): Disable Dashboard overview creation UI when at limit (#97611)
Disables the UI that allows users to create a new dashboard. "Add to Dashboard" flows are not tackled in this PR because I want to split up the changes. - Adds a hook that queries for `subscriptions` and if there is a subscription, reads the limit from the `planDetails` - If the subscription says there is a limit, conduct a query to get `limit + 1` dashboards - We need to account for 1 in the request because the General dashboard is currently included - If the number of dashboards equals or exceeds the limit, return that `hasReachedDashboardLimit` is `true` - Disables the following UI using `hasReachedDashboardLimit`: - Create Dashboard - Duplicate (from grid view, and both table views) - Adding templates We should also add a proper upsell tooltip but for now this is okay.
1 parent 115ea08 commit ff641e8

File tree

17 files changed

+694
-83
lines changed

17 files changed

+694
-83
lines changed

static/app/types/hooks.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ export type GithubInstallationInstallButtonProps = {
156156
installationID: SelectKey;
157157
isSaving: boolean;
158158
};
159+
160+
type DashboardLimitProviderProps = {
161+
children:
162+
| ((limitData: {
163+
dashboardsLimit: number;
164+
hasReachedDashboardLimit: boolean;
165+
isLoading: boolean;
166+
limitMessage: React.ReactNode | null;
167+
}) => React.ReactNode)
168+
| React.ReactNode;
169+
};
170+
159171
/**
160172
* Component wrapping hooks
161173
*/
@@ -168,6 +180,7 @@ type ComponentHooks = {
168180
'component:crons-list-page-header': () => React.ComponentType<CronsBillingBannerProps>;
169181
'component:crons-onboarding-panel': () => React.ComponentType<CronsOnboardingPanelProps>;
170182
'component:dashboards-header': () => React.ComponentType<DashboardHeadersProps>;
183+
'component:dashboards-limit-provider': () => React.ComponentType<DashboardLimitProviderProps>;
171184
'component:data-consent-banner': () => React.ComponentType<{source: string}> | null;
172185
'component:data-consent-org-creation-checkbox': () => React.ComponentType | null;
173186
'component:data-consent-priority-learn-more': () => React.ComponentType | null;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import HookOrDefault from 'sentry/components/hookOrDefault';
2+
3+
export const DashboardCreateLimitWrapper = HookOrDefault({
4+
hookName: 'component:dashboards-limit-provider',
5+
defaultComponent: ({children}) =>
6+
typeof children === 'function'
7+
? children({
8+
hasReachedDashboardLimit: false,
9+
dashboardsLimit: 0,
10+
isLoading: false,
11+
limitMessage: null,
12+
})
13+
: children,
14+
});

static/app/views/dashboards/manage/dashboardGrid.tsx

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {Organization} from 'sentry/types/organization';
1919
import {trackAnalytics} from 'sentry/utils/analytics';
2020
import {useQueryClient} from 'sentry/utils/queryClient';
2121
import withApi from 'sentry/utils/withApi';
22+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
2223
import {useDeleteDashboard} from 'sentry/views/dashboards/hooks/useDeleteDashboard';
2324
import {useDuplicateDashboard} from 'sentry/views/dashboards/hooks/useDuplicateDashboard';
2425
import {
@@ -86,7 +87,12 @@ function DashboardGrid({
8687
});
8788
}
8889

89-
function renderDropdownMenu(dashboard: DashboardListItem) {
90+
function renderDropdownMenu(dashboard: DashboardListItem, dashboardLimitData: any) {
91+
const {
92+
hasReachedDashboardLimit,
93+
isLoading: isLoadingDashboardsLimit,
94+
limitMessage,
95+
} = dashboardLimitData;
9096
const menuItems: MenuItemProps[] = [
9197
{
9298
key: 'dashboard-duplicate',
@@ -98,6 +104,8 @@ function DashboardGrid({
98104
onConfirm: () => handleDuplicateDashboard(dashboard, 'grid'),
99105
});
100106
},
107+
disabled: hasReachedDashboardLimit || isLoadingDashboardsLimit,
108+
tooltip: limitMessage,
101109
},
102110
{
103111
key: 'dashboard-delete',
@@ -160,23 +168,28 @@ function DashboardGrid({
160168

161169
return currentDashboards?.slice(0, rowCount * columnCount).map((dashboard, index) => {
162170
return (
163-
<DashboardCard
164-
key={`${index}-${dashboard.id}`}
165-
title={dashboard.title}
166-
to={{
167-
pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
168-
...queryLocation,
169-
}}
170-
detail={tn('%s widget', '%s widgets', dashboard.widgetPreview.length)}
171-
dateStatus={
172-
dashboard.dateCreated ? <TimeSince date={dashboard.dateCreated} /> : undefined
173-
}
174-
createdBy={dashboard.createdBy}
175-
renderWidgets={() => renderGridPreview(dashboard)}
176-
renderContextMenu={() => renderDropdownMenu(dashboard)}
177-
isFavorited={dashboard.isFavorited}
178-
onFavorite={isFavorited => handleFavorite(dashboard, isFavorited)}
179-
/>
171+
<DashboardCreateLimitWrapper key={`${index}-${dashboard.id}`}>
172+
{dashboardLimitData => (
173+
<DashboardCard
174+
title={dashboard.title}
175+
to={{
176+
pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
177+
...queryLocation,
178+
}}
179+
detail={tn('%s widget', '%s widgets', dashboard.widgetPreview.length)}
180+
dateStatus={
181+
dashboard.dateCreated ? (
182+
<TimeSince date={dashboard.dateCreated} />
183+
) : undefined
184+
}
185+
createdBy={dashboard.createdBy}
186+
renderWidgets={() => renderGridPreview(dashboard)}
187+
renderContextMenu={() => renderDropdownMenu(dashboard, dashboardLimitData)}
188+
isFavorited={dashboard.isFavorited}
189+
onFavorite={isFavorited => handleFavorite(dashboard, isFavorited)}
190+
/>
191+
)}
192+
</DashboardCreateLimitWrapper>
180193
);
181194
});
182195
}

static/app/views/dashboards/manage/dashboardTable.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
2929
import {useQueryClient} from 'sentry/utils/queryClient';
3030
import {decodeScalar} from 'sentry/utils/queryString';
3131
import withApi from 'sentry/utils/withApi';
32+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
3233
import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
3334
import {useDeleteDashboard} from 'sentry/views/dashboards/hooks/useDeleteDashboard';
3435
import {useDuplicateDashboard} from 'sentry/views/dashboards/hooks/useDuplicateDashboard';
@@ -267,20 +268,30 @@ function DashboardTable({
267268
)}
268269
</DateSelected>
269270
<ActionsIconWrapper>
270-
<StyledButton
271-
onClick={e => {
272-
e.stopPropagation();
273-
openConfirmModal({
274-
message: t('Are you sure you want to duplicate this dashboard?'),
275-
priority: 'primary',
276-
onConfirm: () => handleDuplicateDashboard(dataRow, 'table'),
277-
});
278-
}}
279-
aria-label={t('Duplicate Dashboard')}
280-
data-test-id={'dashboard-duplicate'}
281-
icon={<IconCopy />}
282-
size="sm"
283-
/>
271+
<DashboardCreateLimitWrapper>
272+
{({
273+
hasReachedDashboardLimit,
274+
isLoading: isLoadingDashboardsLimit,
275+
limitMessage,
276+
}) => (
277+
<StyledButton
278+
onClick={e => {
279+
e.stopPropagation();
280+
openConfirmModal({
281+
message: t('Are you sure you want to duplicate this dashboard?'),
282+
priority: 'primary',
283+
onConfirm: () => handleDuplicateDashboard(dataRow, 'table'),
284+
});
285+
}}
286+
aria-label={t('Duplicate Dashboard')}
287+
data-test-id={'dashboard-duplicate'}
288+
icon={<IconCopy />}
289+
size="sm"
290+
disabled={hasReachedDashboardLimit || isLoadingDashboardsLimit}
291+
title={limitMessage}
292+
/>
293+
)}
294+
</DashboardCreateLimitWrapper>
284295
<StyledButton
285296
onClick={e => {
286297
e.stopPropagation();

static/app/views/dashboards/manage/index.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
3737
import {useLocation} from 'sentry/utils/useLocation';
3838
import {useNavigate} from 'sentry/utils/useNavigate';
3939
import useOrganization from 'sentry/utils/useOrganization';
40+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
4041
import {getDashboardTemplates} from 'sentry/views/dashboards/data';
4142
import {useOwnedDashboards} from 'sentry/views/dashboards/hooks/useOwnedDashboards';
4243
import {
@@ -562,18 +563,30 @@ function ManageDashboards() {
562563
/>
563564
</TemplateSwitch>
564565
<FeedbackWidgetButton />
565-
<Button
566-
data-test-id="dashboard-create"
567-
onClick={event => {
568-
event.preventDefault();
569-
onCreate();
570-
}}
571-
size="sm"
572-
priority="primary"
573-
icon={<IconAdd />}
574-
>
575-
{t('Create Dashboard')}
576-
</Button>
566+
<DashboardCreateLimitWrapper>
567+
{({
568+
hasReachedDashboardLimit,
569+
isLoading: isLoadingDashboardsLimit,
570+
limitMessage,
571+
}) => (
572+
<Button
573+
data-test-id="dashboard-create"
574+
onClick={event => {
575+
event.preventDefault();
576+
onCreate();
577+
}}
578+
size="sm"
579+
priority="primary"
580+
icon={<IconAdd />}
581+
disabled={
582+
hasReachedDashboardLimit || isLoadingDashboardsLimit
583+
}
584+
title={limitMessage}
585+
>
586+
{t('Create Dashboard')}
587+
</Button>
588+
)}
589+
</DashboardCreateLimitWrapper>
577590
<Feature features="dashboards-import">
578591
<Button
579592
onClick={() => {

static/app/views/dashboards/manage/tableView/table.tsx

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {useQueryClient} from 'sentry/utils/queryClient';
1414
import useApi from 'sentry/utils/useApi';
1515
import {useNavigate} from 'sentry/utils/useNavigate';
1616
import useOrganization from 'sentry/utils/useOrganization';
17+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
1718
import {useDeleteDashboard} from 'sentry/views/dashboards/hooks/useDeleteDashboard';
1819
import {useDuplicateDashboard} from 'sentry/views/dashboards/hooks/useDuplicateDashboard';
1920
import {useResetDashboardLists} from 'sentry/views/dashboards/hooks/useResetDashboardLists';
@@ -149,33 +150,44 @@ export function DashboardTable({
149150
<SavedEntityTable.CellTimeSince date={dashboard.dateCreated ?? null} />
150151
</SavedEntityTable.Cell>
151152
<SavedEntityTable.Cell data-column="actions" hasButton>
152-
<SavedEntityTable.CellActions
153-
items={[
154-
{
155-
key: 'duplicate',
156-
label: t('Duplicate'),
157-
onAction: () => handleDuplicateDashboard(dashboard, 'table'),
158-
},
159-
...(dashboard.createdBy === null
160-
? []
161-
: [
162-
{
163-
key: 'delete',
164-
label: t('Delete'),
165-
priority: 'danger' as const,
166-
onAction: () => {
167-
openConfirmModal({
168-
message: t(
169-
'Are you sure you want to delete this dashboard?'
170-
),
171-
priority: 'danger',
172-
onConfirm: () => handleDeleteDashboard(dashboard, 'table'),
173-
});
174-
},
175-
},
176-
]),
177-
]}
178-
/>
153+
<DashboardCreateLimitWrapper>
154+
{({
155+
hasReachedDashboardLimit,
156+
isLoading: isLoadingDashboardsLimit,
157+
limitMessage,
158+
}) => (
159+
<SavedEntityTable.CellActions
160+
items={[
161+
{
162+
key: 'duplicate',
163+
label: t('Duplicate'),
164+
onAction: () => handleDuplicateDashboard(dashboard, 'table'),
165+
disabled: hasReachedDashboardLimit || isLoadingDashboardsLimit,
166+
tooltip: limitMessage,
167+
},
168+
...(dashboard.createdBy === null
169+
? []
170+
: [
171+
{
172+
key: 'delete',
173+
label: t('Delete'),
174+
priority: 'danger' as const,
175+
onAction: () => {
176+
openConfirmModal({
177+
message: t(
178+
'Are you sure you want to delete this dashboard?'
179+
),
180+
priority: 'danger',
181+
onConfirm: () =>
182+
handleDeleteDashboard(dashboard, 'table'),
183+
});
184+
},
185+
},
186+
]),
187+
]}
188+
/>
189+
)}
190+
</DashboardCreateLimitWrapper>
179191
</SavedEntityTable.Cell>
180192
</SavedEntityTable.Row>
181193
))}

static/app/views/dashboards/manage/templateCard.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {Button} from 'sentry/components/core/button';
66
import {IconAdd, IconGeneric} from 'sentry/icons';
77
import {t} from 'sentry/locale';
88
import {space} from 'sentry/styles/space';
9+
import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper';
910

1011
type Props = {
1112
description: string;
@@ -27,18 +28,28 @@ function TemplateCard({title, description, onPreview, onAdd}: Props) {
2728
</Title>
2829
</Header>
2930
<ButtonContainer>
30-
<StyledButton
31-
onClick={() => {
32-
setIsAddingDashboardTemplate(true);
33-
onAdd().finally(() => {
34-
setIsAddingDashboardTemplate(false);
35-
});
36-
}}
37-
icon={<IconAdd isCircled />}
38-
busy={isAddingDashboardTemplate}
39-
>
40-
{t('Add Dashboard')}
41-
</StyledButton>
31+
<DashboardCreateLimitWrapper>
32+
{({
33+
hasReachedDashboardLimit,
34+
isLoading: isLoadingDashboardsLimit,
35+
limitMessage,
36+
}) => (
37+
<StyledButton
38+
onClick={() => {
39+
setIsAddingDashboardTemplate(true);
40+
onAdd().finally(() => {
41+
setIsAddingDashboardTemplate(false);
42+
});
43+
}}
44+
icon={<IconAdd isCircled />}
45+
busy={isAddingDashboardTemplate}
46+
disabled={hasReachedDashboardLimit || isLoadingDashboardsLimit}
47+
title={limitMessage}
48+
>
49+
{t('Add Dashboard')}
50+
</StyledButton>
51+
)}
52+
</DashboardCreateLimitWrapper>
4253
<StyledButton priority="primary" onClick={onPreview}>
4354
{t('Preview')}
4455
</StyledButton>

static/gsApp/__fixtures__/plan.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function PlanFixture(fields: Partial<Plan>): Plan {
1111
checkoutCategories: [],
1212
availableReservedBudgetTypes: {},
1313
contractInterval: 'monthly',
14+
dashboardLimit: 10,
1415
description: '',
1516
features: [],
1617
hasOnDemandModes: false,

0 commit comments

Comments
 (0)