Scenes/ShareModal: Implement public dashboard tab (#76837)

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
kay delaney 2023-11-14 17:05:24 +00:00 committed by GitHub
parent c506da53f3
commit d0180957d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 520 additions and 124 deletions

View File

@ -3186,10 +3186,6 @@ exports[`better eslint`] = {
"public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx:5381": [ "public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"] [0, 0, 0, "Styles should be written using objects.", "0"]
], ],
"public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx:5381": [ "public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"] [0, 0, 0, "Styles should be written using objects.", "1"]

View File

@ -13,6 +13,7 @@ import { ShareExportTab } from './ShareExportTab';
import { ShareLinkTab } from './ShareLinkTab'; import { ShareLinkTab } from './ShareLinkTab';
import { SharePanelEmbedTab } from './SharePanelEmbedTab'; import { SharePanelEmbedTab } from './SharePanelEmbedTab';
import { ShareSnapshotTab } from './ShareSnapshotTab'; import { ShareSnapshotTab } from './ShareSnapshotTab';
import { SharePublicDashboardTab } from './public-dashboards/SharePublicDashboardTab';
import { ModalSceneObjectLike, SceneShareTab } from './types'; import { ModalSceneObjectLike, SceneShareTab } from './types';
interface ShareModalState extends SceneObjectState { interface ShareModalState extends SceneObjectState {
@ -28,10 +29,10 @@ interface ShareModalState extends SceneObjectState {
export class ShareModal extends SceneObjectBase<ShareModalState> implements ModalSceneObjectLike { export class ShareModal extends SceneObjectBase<ShareModalState> implements ModalSceneObjectLike {
static Component = SharePanelModalRenderer; static Component = SharePanelModalRenderer;
constructor(state: Omit<ShareModalState, 'activeTab'>) { constructor(state: Omit<ShareModalState, 'activeTab'> & { activeTab?: string }) {
super({ super({
...state,
activeTab: 'Link', activeTab: 'Link',
...state,
}); });
this.addActivationHandler(() => this.buildTabs()); this.addActivationHandler(() => this.buildTabs());
@ -50,6 +51,10 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef, modalRef: this.getRef() })); tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef, modalRef: this.getRef() }));
} }
if (Boolean(config.featureToggles['publicDashboards'])) {
tabs.push(new SharePublicDashboardTab({ dashboardRef, modalRef: this.getRef() }));
}
if (panelRef) { if (panelRef) {
tabs.push(new SharePanelEmbedTab({ panelRef, dashboardRef })); tabs.push(new SharePanelEmbedTab({ panelRef, dashboardRef }));
} }
@ -74,14 +79,6 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
// }); // });
// tabs.push(...customDashboardTabs); // tabs.push(...customDashboardTabs);
// } // }
// if (Boolean(config.featureToggles['publicDashboards'])) {
// tabs.push({
// label: 'Public dashboard',
// value: shareDashboardType.publicDashboard,
// component: SharePublicDashboard,
// });
// }
} }
onDismiss = () => { onDismiss = () => {

View File

@ -0,0 +1,75 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { useDeletePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { ConfigPublicDashboardBase } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard';
import { PublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { AccessControlAction } from 'app/types';
import { ShareModal } from '../ShareModal';
import { ConfirmModal } from './ConfirmModal';
import { SharePublicDashboardTab } from './SharePublicDashboardTab';
import { useUnsupportedDatasources } from './hooks';
interface Props extends SceneComponentProps<SharePublicDashboardTab> {
publicDashboard?: PublicDashboard;
isGetLoading?: boolean;
}
export function ConfigPublicDashboard({ model, publicDashboard, isGetLoading }: Props) {
const styles = useStyles2(getStyles);
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const { dashboardRef } = model.useState();
const dashboard = dashboardRef.resolve();
const { isDirty } = dashboard.useState();
const [deletePublicDashboard] = useDeletePublicDashboardMutation();
const hasTemplateVariables = (dashboard.state.$variables?.state.variables.length ?? 0) > 0;
const unsupportedDataSources = useUnsupportedDatasources(dashboard);
const timeRangeState = sceneGraph.getTimeRange(model);
const timeRange = timeRangeState.useState();
return (
<ConfigPublicDashboardBase
dashboard={dashboard}
publicDashboard={publicDashboard}
unsupportedDatasources={unsupportedDataSources}
onRevoke={() => {
dashboard.showModal(
new ConfirmModal({
isOpen: true,
title: 'Revoke public URL',
icon: 'trash-alt',
confirmText: 'Revoke public URL',
body: (
<p className={styles.description}>
Are you sure you want to revoke this URL? The dashboard will no longer be public.
</p>
),
onDismiss: () => {
dashboard.showModal(new ShareModal({ dashboardRef, activeTab: 'Public Dashboard' }));
},
onConfirm: () => {
deletePublicDashboard({ dashboard, dashboardUid: dashboard.state.uid!, uid: publicDashboard!.uid });
dashboard.closeModal();
},
})
);
}}
timeRange={timeRange.value}
showSaveChangesAlert={hasWritePermissions && isDirty}
hasTemplateVariables={hasTemplateVariables}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
description: css({
fontSize: theme.typography.body.fontSize,
}),
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { ConfirmModal as ConfirmModalComponent, ConfirmModalProps } from '@grafana/ui';
import { ModalSceneObjectLike } from '../types';
interface ConfirmModalState extends ConfirmModalProps, SceneObjectState {}
export class ConfirmModal extends SceneObjectBase<ConfirmModalState> implements ModalSceneObjectLike {
static Component = ConfirmModalRenderer;
constructor(state: ConfirmModalState) {
super({
confirmVariant: 'destructive',
dismissText: 'Cancel',
dismissVariant: 'secondary',
icon: 'exclamation-triangle',
confirmButtonVariant: 'destructive',
...state,
});
}
onDismiss() {}
}
function ConfirmModalRenderer({ model }: SceneComponentProps<ConfirmModal>) {
const props = model.useState();
return <ConfirmModalComponent {...props} />;
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { SceneComponentProps } from '@grafana/scenes';
import { CreatePublicDashboardBase } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/CreatePublicDashboard/CreatePublicDashboard';
import { SharePublicDashboardTab } from './SharePublicDashboardTab';
import { useUnsupportedDatasources } from './hooks';
export function CreatePublicDashboard({ model }: SceneComponentProps<SharePublicDashboardTab>) {
const { dashboardRef } = model.useState();
const dashboard = dashboardRef.resolve();
const unsupportedDataSources = useUnsupportedDatasources(dashboard);
const hasTemplateVariables = (dashboard.state.$variables?.state.variables.length ?? 0) > 0;
return (
<CreatePublicDashboardBase
dashboard={dashboard}
unsupportedDatasources={unsupportedDataSources}
unsupportedTemplateVariables={hasTemplateVariables}
/>
);
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { SceneComponentProps, SceneObjectBase, SceneObjectRef } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { useGetPublicDashboardQuery } from 'app/features/dashboard/api/publicDashboardApi';
import { Loader } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
import { publicDashboardPersisted } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardScene } from '../../scene/DashboardScene';
import { SceneShareTabState } from '../types';
import { ConfigPublicDashboard } from './ConfigPublicDashboard';
import { CreatePublicDashboard } from './CreatePublicDashboard';
export interface SharePublicDashboardTabState extends SceneShareTabState {
dashboardRef: SceneObjectRef<DashboardScene>;
}
export class SharePublicDashboardTab extends SceneObjectBase<SharePublicDashboardTabState> {
static Component = SharePublicDashboardTabRenderer;
public getTabLabel() {
return t('share-modal.tab-title.public-dashboard', 'Public Dashboard');
}
}
function SharePublicDashboardTabRenderer({ model }: SceneComponentProps<SharePublicDashboardTab>) {
const { data: publicDashboard, isLoading: isGetLoading } = useGetPublicDashboardQuery(
model.state.dashboardRef.resolve().state.uid!
);
return (
<>
{isGetLoading ? (
<Loader />
) : !publicDashboardPersisted(publicDashboard) ? (
<CreatePublicDashboard model={model} />
) : (
<ConfigPublicDashboard model={model} publicDashboard={publicDashboard} isGetLoading={isGetLoading} />
)}
</>
);
}

View File

@ -0,0 +1,14 @@
import { useAsync } from 'react-use';
import { DashboardScene } from '../../scene/DashboardScene';
import { getPanelDatasourceTypes, getUnsupportedDashboardDatasources } from './utils';
export function useUnsupportedDatasources(dashboard: DashboardScene) {
const { value: unsupportedDataSources } = useAsync(async () => {
const types = getPanelDatasourceTypes(dashboard);
return getUnsupportedDashboardDatasources(types);
}, []);
return unsupportedDataSources;
}

View File

@ -0,0 +1,102 @@
import { DataSourceWithBackend } from '@grafana/runtime';
import {
SceneGridItemLike,
VizPanel,
SceneGridItem,
SceneQueryRunner,
SceneDataTransformer,
SceneGridLayout,
SceneGridRow,
} from '@grafana/scenes';
import { supportedDatasources } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SupportedPubdashDatasources';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { DashboardScene } from '../../scene/DashboardScene';
import { LibraryVizPanel } from '../../scene/LibraryVizPanel';
import { PanelRepeaterGridItem } from '../../scene/PanelRepeaterGridItem';
export const getUnsupportedDashboardDatasources = async (types: string[]): Promise<string[]> => {
let unsupportedDS = new Set<string>();
for (const type of types) {
if (!supportedDatasources.has(type)) {
unsupportedDS.add(type);
} else {
const ds = await getDatasourceSrv().get(type);
if (!(ds instanceof DataSourceWithBackend)) {
unsupportedDS.add(type);
}
}
}
return Array.from(unsupportedDS);
};
export function getPanelDatasourceTypes(scene: DashboardScene): string[] {
const types = new Set<string>();
const body = scene.state.body;
if (!(body instanceof SceneGridLayout)) {
return [];
}
for (const child of body.state.children) {
if (child instanceof SceneGridItem) {
const ts = panelDatasourceTypes(child);
for (const t of ts) {
types.add(t);
}
}
if (child instanceof SceneGridRow) {
const ts = rowTypes(child);
for (const t of ts) {
types.add(t);
}
}
}
return Array.from(types).sort();
}
function rowTypes(gridRow: SceneGridRow) {
const types = new Set(gridRow.state.children.map((c) => panelDatasourceTypes(c)).flat());
return types;
}
function panelDatasourceTypes(gridItem: SceneGridItemLike) {
let vizPanel: VizPanel | undefined;
if (gridItem instanceof SceneGridItem) {
if (gridItem.state.body instanceof LibraryVizPanel) {
vizPanel = gridItem.state.body.state.panel;
} else if (gridItem.state.body instanceof VizPanel) {
vizPanel = gridItem.state.body;
} else {
throw new Error('SceneGridItem body expected to be VizPanel');
}
} else if (gridItem instanceof PanelRepeaterGridItem) {
vizPanel = gridItem.state.source;
}
if (!vizPanel) {
throw new Error('Unsupported grid item type');
}
const dataProvider = vizPanel.state.$data;
const types = new Set<string>();
if (dataProvider instanceof SceneQueryRunner) {
for (const q of dataProvider.state.queries) {
types.add(q.datasource?.type ?? '');
}
}
if (dataProvider instanceof SceneDataTransformer) {
const panelData = dataProvider.state.$data;
if (panelData instanceof SceneQueryRunner) {
for (const q of panelData.state.queries) {
types.add(q.datasource?.type ?? '');
}
}
}
return Array.from(types);
}

View File

@ -11,6 +11,7 @@ import {
SessionUser, SessionUser,
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { import {
PublicDashboardListWithPagination, PublicDashboardListWithPagination,
PublicDashboardListWithPaginationResponse, PublicDashboardListWithPaginationResponse,
@ -69,40 +70,61 @@ export const publicDashboardApi = createApi({
}), }),
createPublicDashboard: builder.mutation< createPublicDashboard: builder.mutation<
PublicDashboard, PublicDashboard,
{ dashboard: DashboardModel; payload: Partial<PublicDashboardSettings> } { dashboard: DashboardModel | DashboardScene; payload: Partial<PublicDashboardSettings> }
>({ >({
query: (params) => ({ query: (params) => {
url: `/dashboards/uid/${params.dashboard.uid}/public-dashboards`, const dashUid = params.dashboard instanceof DashboardScene ? params.dashboard.state.uid : params.dashboard.uid;
method: 'POST', return {
data: params.payload, url: `/dashboards/uid/${dashUid}/public-dashboards`,
}), method: 'POST',
data: params.payload,
};
},
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) { async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled; const { data } = await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Dashboard is public!'))); dispatch(notifyApp(createSuccessNotification('Dashboard is public!')));
// Update runtime meta flag if (dashboard instanceof DashboardScene) {
dashboard.updateMeta({ dashboard.setState({
publicDashboardUid: data.uid, meta: { ...dashboard.state.meta, publicDashboardEnabled: data.isEnabled, publicDashboardUid: data.uid },
publicDashboardEnabled: data.isEnabled, });
}); } else {
// Update runtime meta flag
dashboard.updateMeta({
publicDashboardUid: data.uid,
publicDashboardEnabled: data.isEnabled,
});
}
}, },
invalidatesTags: (result, error, { dashboard }) => [{ type: 'PublicDashboard', id: dashboard.uid }], invalidatesTags: (result, error, { dashboard }) => [
{ type: 'PublicDashboard', id: dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid },
],
}), }),
updatePublicDashboard: builder.mutation< updatePublicDashboard: builder.mutation<
PublicDashboard, PublicDashboard,
{ dashboard: Partial<DashboardModel>; payload: Partial<PublicDashboard> } {
dashboard: (Pick<DashboardModel, 'uid'> & Partial<Pick<DashboardModel, 'updateMeta'>>) | DashboardScene;
payload: Partial<PublicDashboard>;
}
>({ >({
query: (params) => ({ query: ({ payload, dashboard }) => {
url: `/dashboards/uid/${params.dashboard.uid}/public-dashboards/${params.payload.uid}`, const dashUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
method: 'PATCH', return {
data: params.payload, url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`,
}), method: 'PATCH',
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) { data: payload,
};
},
async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled; const { data } = await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!'))); dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
if (dashboard.updateMeta) { if (dashboard instanceof DashboardScene) {
dashboard.updateMeta({ dashboard.setState({
meta: { ...dashboard.state.meta, publicDashboardEnabled: data.isEnabled, publicDashboardUid: data.uid },
});
} else {
dashboard.updateMeta?.({
publicDashboardUid: data.uid, publicDashboardUid: data.uid,
publicDashboardEnabled: data.isEnabled, publicDashboardEnabled: data.isEnabled,
}); });
@ -150,7 +172,10 @@ export const publicDashboardApi = createApi({
}), }),
providesTags: ['AuditTablePublicDashboard'], providesTags: ['AuditTablePublicDashboard'],
}), }),
deletePublicDashboard: builder.mutation<void, { dashboard?: DashboardModel; dashboardUid: string; uid: string }>({ deletePublicDashboard: builder.mutation<
void,
{ dashboard?: DashboardModel | DashboardScene; dashboardUid: string; uid: string }
>({
query: (params) => ({ query: (params) => ({
url: `/dashboards/uid/${params.dashboardUid}/public-dashboards/${params.uid}`, url: `/dashboards/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
method: 'DELETE', method: 'DELETE',
@ -159,10 +184,16 @@ export const publicDashboardApi = createApi({
await queryFulfilled; await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Public dashboard deleted!'))); dispatch(notifyApp(createSuccessNotification('Public dashboard deleted!')));
dashboard?.updateMeta({ if (dashboard instanceof DashboardScene) {
publicDashboardUid: uid, dashboard.setState({
publicDashboardEnabled: false, meta: { ...dashboard.state.meta, publicDashboardUid: uid, publicDashboardEnabled: false },
}); });
} else {
dashboard?.updateMeta({
publicDashboardUid: uid,
publicDashboardEnabled: false,
});
}
}, },
invalidatesTags: (result, error, { dashboardUid }) => [ invalidatesTags: (result, error, { dashboardUid }) => [
{ type: 'PublicDashboard', id: dashboardUid }, { type: 'PublicDashboard', id: dashboardUid },

View File

@ -1,27 +1,33 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useContext } from 'react'; import React from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data/src'; import { GrafanaTheme2, TimeRange } from '@grafana/data/src';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { config, featureEnabled } from '@grafana/runtime/src'; import { config, featureEnabled } from '@grafana/runtime/src';
import { import {
Button,
ClipboardButton, ClipboardButton,
Field, Field,
HorizontalGroup, HorizontalGroup,
Input, Input,
Label, Label,
ModalsContext, ModalsController,
Switch, Switch,
useStyles2, useStyles2,
} from '@grafana/ui/src'; } from '@grafana/ui/src';
import { Layout } from '@grafana/ui/src/components/Layout/Layout'; import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import {
useDeletePublicDashboardMutation,
useUpdatePublicDashboardMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import { DashboardModel } from 'app/features/dashboard/state';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { DeletePublicDashboardModal } from 'app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal';
import { contextSrv } from '../../../../../../core/services/context_srv'; import { contextSrv } from '../../../../../../core/services/context_srv';
import { AccessControlAction, useSelector } from '../../../../../../types'; import { AccessControlAction, useSelector } from '../../../../../../types';
import { DeletePublicDashboardButton } from '../../../../../manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton';
import { useGetPublicDashboardQuery, useUpdatePublicDashboardMutation } from '../../../../api/publicDashboardApi';
import { useIsDesktop } from '../../../../utils/screen'; import { useIsDesktop } from '../../../../utils/screen';
import { ShareModal } from '../../ShareModal'; import { ShareModal } from '../../ShareModal';
import { trackDashboardSharingActionPerType } from '../../analytics'; import { trackDashboardSharingActionPerType } from '../../analytics';
@ -30,8 +36,11 @@ import { NoUpsertPermissionsAlert } from '../ModalAlerts/NoUpsertPermissionsAler
import { SaveDashboardChangesAlert } from '../ModalAlerts/SaveDashboardChangesAlert'; import { SaveDashboardChangesAlert } from '../ModalAlerts/SaveDashboardChangesAlert';
import { UnsupportedDataSourcesAlert } from '../ModalAlerts/UnsupportedDataSourcesAlert'; import { UnsupportedDataSourcesAlert } from '../ModalAlerts/UnsupportedDataSourcesAlert';
import { UnsupportedTemplateVariablesAlert } from '../ModalAlerts/UnsupportedTemplateVariablesAlert'; import { UnsupportedTemplateVariablesAlert } from '../ModalAlerts/UnsupportedTemplateVariablesAlert';
import { dashboardHasTemplateVariables, generatePublicDashboardUrl } from '../SharePublicDashboardUtils'; import {
import { useGetUnsupportedDataSources } from '../useGetUnsupportedDataSources'; dashboardHasTemplateVariables,
generatePublicDashboardUrl,
PublicDashboard,
} from '../SharePublicDashboardUtils';
import { Configuration } from './Configuration'; import { Configuration } from './Configuration';
import { EmailSharingConfiguration } from './EmailSharingConfiguration'; import { EmailSharingConfiguration } from './EmailSharingConfiguration';
@ -46,25 +55,33 @@ export interface ConfigPublicDashboardForm {
isPaused: boolean; isPaused: boolean;
} }
const ConfigPublicDashboard = () => { interface Props {
unsupportedDatasources?: string[];
showSaveChangesAlert?: boolean;
publicDashboard?: PublicDashboard;
hasTemplateVariables?: boolean;
timeRange: TimeRange;
onRevoke: () => void;
dashboard: DashboardModel | DashboardScene;
}
export function ConfigPublicDashboardBase({
onRevoke,
timeRange,
hasTemplateVariables = false,
showSaveChangesAlert = false,
unsupportedDatasources = [],
publicDashboard,
dashboard,
}: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const { showModal, hideModal } = useContext(ModalsContext);
const [update, { isLoading }] = useUpdatePublicDashboardMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite); const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const disableInputs = !hasWritePermissions || isLoading;
const hasEmailSharingEnabled = const hasEmailSharingEnabled =
!!config.featureToggles.publicDashboardsEmailSharing && featureEnabled('publicDashboardsEmailSharing'); !!config.featureToggles.publicDashboardsEmailSharing && featureEnabled('publicDashboardsEmailSharing');
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const dashboardVariables = dashboard.getVariables();
const { unsupportedDataSources } = useGetUnsupportedDataSources(dashboard);
const { data: publicDashboard, isFetching: isGetLoading } = useGetPublicDashboardQuery(dashboard.uid);
const [update, { isLoading: isUpdateLoading }] = useUpdatePublicDashboardMutation();
const isDataLoading = isUpdateLoading || isGetLoading;
const disableInputs = !hasWritePermissions || isDataLoading;
const timeRange = getTimeRange(dashboard.getDefaultTime(), dashboard);
const { handleSubmit, setValue, register } = useForm<ConfigPublicDashboardForm>({ const { handleSubmit, setValue, register } = useForm<ConfigPublicDashboardForm>({
defaultValues: { defaultValues: {
@ -74,33 +91,23 @@ const ConfigPublicDashboard = () => {
}, },
}); });
const onUpdate = async (values: ConfigPublicDashboardForm) => { const onPublicDashboardUpdate = async (values: ConfigPublicDashboardForm) => {
const { isAnnotationsEnabled, isTimeSelectionEnabled, isPaused } = values; const { isAnnotationsEnabled, isTimeSelectionEnabled, isPaused } = values;
const req = { update({
dashboard, dashboard: dashboard,
payload: { payload: {
...publicDashboard!, ...publicDashboard!,
annotationsEnabled: isAnnotationsEnabled, annotationsEnabled: isAnnotationsEnabled,
timeSelectionEnabled: isTimeSelectionEnabled, timeSelectionEnabled: isTimeSelectionEnabled,
isEnabled: !isPaused, isEnabled: !isPaused,
}, },
}; });
update(req);
}; };
const onChange = async (name: keyof ConfigPublicDashboardForm, value: boolean) => { const onChange = async (name: keyof ConfigPublicDashboardForm, value: boolean) => {
setValue(name, value); setValue(name, value);
await handleSubmit((data) => onUpdate(data))(); await handleSubmit((data) => onPublicDashboardUpdate(data))();
};
const onDismissDelete = () => {
showModal(ShareModal, {
dashboard,
onDismiss: hideModal,
activeTab: shareDashboardType.publicDashboard,
});
}; };
function onCopyURL() { function onCopyURL() {
@ -109,11 +116,11 @@ const ConfigPublicDashboard = () => {
return ( return (
<div className={styles.configContainer}> <div className={styles.configContainer}>
{hasWritePermissions && dashboard.hasUnsavedChanges() && <SaveDashboardChangesAlert />} {showSaveChangesAlert && <SaveDashboardChangesAlert />}
{!hasWritePermissions && <NoUpsertPermissionsAlert mode="edit" />} {!hasWritePermissions && <NoUpsertPermissionsAlert mode="edit" />}
{dashboardHasTemplateVariables(dashboardVariables) && <UnsupportedTemplateVariablesAlert />} {hasTemplateVariables && <UnsupportedTemplateVariablesAlert />}
{!!unsupportedDataSources.length && ( {unsupportedDatasources.length > 0 && (
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} /> <UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDatasources.join(', ')} />
)} )}
{hasEmailSharingEnabled && <EmailSharingConfiguration />} {hasEmailSharingEnabled && <EmailSharingConfiguration />}
@ -168,7 +175,7 @@ const ConfigPublicDashboard = () => {
headerElement={({ className }) => ( headerElement={({ className }) => (
<SettingsSummary <SettingsSummary
className={className} className={className}
isDataLoading={isDataLoading} isDataLoading={isLoading}
timeRange={timeRange} timeRange={timeRange}
timeSelectionEnabled={publicDashboard?.timeSelectionEnabled} timeSelectionEnabled={publicDashboard?.timeSelectionEnabled}
annotationsEnabled={publicDashboard?.annotationsEnabled} annotationsEnabled={publicDashboard?.annotationsEnabled}
@ -186,27 +193,73 @@ const ConfigPublicDashboard = () => {
align={isDesktop ? 'center' : 'normal'} align={isDesktop ? 'center' : 'normal'}
> >
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">
<DeletePublicDashboardButton <Button
aria-label="Revoke public URL"
title="Revoke public URL"
onClick={onRevoke}
type="button" type="button"
disabled={disableInputs} disabled={disableInputs}
data-testid={selectors.DeleteButton} data-testid={selectors.DeleteButton}
onDismiss={onDismissDelete}
variant="destructive" variant="destructive"
fill="outline" fill="outline"
dashboard={dashboard}
publicDashboard={{
uid: publicDashboard!.uid,
dashboardUid: dashboard.uid,
title: dashboard.title,
}}
> >
Revoke public URL Revoke public URL
</DeletePublicDashboardButton> </Button>
</HorizontalGroup> </HorizontalGroup>
</Layout> </Layout>
</div> </div>
); );
}; }
interface ConfigPublicDashboardProps {
publicDashboard: PublicDashboard;
unsupportedDatasources: string[];
}
export function ConfigPublicDashboard({ publicDashboard, unsupportedDatasources }: ConfigPublicDashboardProps) {
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const timeRange = getTimeRange(dashboard.getDefaultTime(), dashboard);
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const hasTemplateVariables = dashboardHasTemplateVariables(dashboard.getVariables());
const [deletePublicDashboard] = useDeletePublicDashboardMutation();
const onDeletePublicDashboardClick = (onDelete: () => void) => {
deletePublicDashboard({
dashboard,
uid: publicDashboard!.uid,
dashboardUid: dashboard.uid,
});
onDelete();
};
return (
<ModalsController>
{({ showModal, hideModal }) => (
<ConfigPublicDashboardBase
publicDashboard={publicDashboard}
dashboard={dashboard}
unsupportedDatasources={unsupportedDatasources}
timeRange={timeRange}
showSaveChangesAlert={hasWritePermissions && dashboard.hasUnsavedChanges()}
hasTemplateVariables={hasTemplateVariables}
onRevoke={() => {
showModal(DeletePublicDashboardModal, {
dashboardTitle: dashboard.title,
onConfirm: () => onDeletePublicDashboardClick(hideModal),
onDismiss: () => {
showModal(ShareModal, {
dashboard,
onDismiss: hideModal,
activeTab: shareDashboardType.publicDashboard,
});
},
});
}}
/>
)}
</ModalsController>
);
}
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
configContainer: css` configContainer: css`
@ -225,5 +278,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'inline-block', display: 'inline-block',
}), }),
}); });
export default ConfigPublicDashboard;

View File

@ -5,10 +5,12 @@ import { FormState, UseFormRegister } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data/src'; import { GrafanaTheme2 } from '@grafana/data/src';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { Button, Form, Spinner, useStyles2 } from '@grafana/ui/src'; import { Button, Form, Spinner, useStyles2 } from '@grafana/ui/src';
import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { DashboardModel } from 'app/features/dashboard/state';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { contextSrv } from '../../../../../../core/services/context_srv'; import { contextSrv } from '../../../../../../core/services/context_srv';
import { AccessControlAction, useSelector } from '../../../../../../types'; import { AccessControlAction, useSelector } from '../../../../../../types';
import { useCreatePublicDashboardMutation } from '../../../../api/publicDashboardApi';
import { trackDashboardSharingActionPerType } from '../../analytics'; import { trackDashboardSharingActionPerType } from '../../analytics';
import { shareDashboardType } from '../../utils'; import { shareDashboardType } from '../../utils';
import { NoUpsertPermissionsAlert } from '../ModalAlerts/NoUpsertPermissionsAlert'; import { NoUpsertPermissionsAlert } from '../ModalAlerts/NoUpsertPermissionsAlert';
@ -27,22 +29,29 @@ export type SharePublicDashboardAcknowledgmentInputs = {
usageAcknowledgment: boolean; usageAcknowledgment: boolean;
}; };
const CreatePublicDashboard = ({ isError }: { isError: boolean }) => { interface CreatePublicDashboarBaseProps {
unsupportedDatasources?: string[];
unsupportedTemplateVariables?: boolean;
dashboard: DashboardModel | DashboardScene;
hasError?: boolean;
}
export const CreatePublicDashboardBase = ({
unsupportedDatasources = [],
unsupportedTemplateVariables = false,
dashboard,
hasError = false,
}: CreatePublicDashboarBaseProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite); const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const dashboardState = useSelector((store) => store.dashboard); const [createPublicDashboard, { isLoading, isError }] = useCreatePublicDashboardMutation();
const dashboard = dashboardState.getModel()!; const onCreate = () => {
const { unsupportedDataSources } = useGetUnsupportedDataSources(dashboard);
const [createPublicDashboard, { isLoading: isSaveLoading }] = useCreatePublicDashboardMutation();
const disableInputs = !hasWritePermissions || isSaveLoading || isError;
const onCreate = async () => {
trackDashboardSharingActionPerType('generate_public_url', shareDashboardType.publicDashboard);
createPublicDashboard({ dashboard, payload: { isEnabled: true } }); createPublicDashboard({ dashboard, payload: { isEnabled: true } });
trackDashboardSharingActionPerType('generate_public_url', shareDashboardType.publicDashboard);
}; };
const disableInputs = !hasWritePermissions || isLoading || isError || hasError;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div> <div>
@ -52,10 +61,10 @@ const CreatePublicDashboard = ({ isError }: { isError: boolean }) => {
{!hasWritePermissions && <NoUpsertPermissionsAlert mode="create" />} {!hasWritePermissions && <NoUpsertPermissionsAlert mode="create" />}
{dashboardHasTemplateVariables(dashboard.getVariables()) && <UnsupportedTemplateVariablesAlert />} {unsupportedTemplateVariables && <UnsupportedTemplateVariablesAlert />}
{!!unsupportedDataSources.length && ( {unsupportedDatasources.length > 0 && (
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} /> <UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDatasources.join(', ')} />
)} )}
<Form onSubmit={onCreate} validateOn="onChange" maxWidth="none"> <Form onSubmit={onCreate} validateOn="onChange" maxWidth="none">
@ -72,7 +81,7 @@ const CreatePublicDashboard = ({ isError }: { isError: boolean }) => {
</div> </div>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<Button type="submit" disabled={disableInputs || !isValid} data-testid={selectors.CreateButton}> <Button type="submit" disabled={disableInputs || !isValid} data-testid={selectors.CreateButton}>
Generate public URL {isSaveLoading && <Spinner className={styles.loadingSpinner} />} Generate public URL {isLoading && <Spinner className={styles.loadingSpinner} />}
</Button> </Button>
</div> </div>
</> </>
@ -82,6 +91,22 @@ const CreatePublicDashboard = ({ isError }: { isError: boolean }) => {
); );
}; };
export function CreatePublicDashboard({ hasError }: { hasError?: boolean }) {
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const { unsupportedDataSources } = useGetUnsupportedDataSources(dashboard);
const hasTemplateVariables = dashboardHasTemplateVariables(dashboard.getVariables());
return (
<CreatePublicDashboardBase
dashboard={dashboard}
unsupportedDatasources={unsupportedDataSources}
unsupportedTemplateVariables={hasTemplateVariables}
hasError={hasError}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
container: css` container: css`
display: flex; display: flex;
@ -107,5 +132,3 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-left: ${theme.spacing(1)}; margin-left: ${theme.spacing(1)};
`, `,
}); });
export default CreatePublicDashboard;

View File

@ -6,14 +6,17 @@ import { Spinner, useStyles2 } from '@grafana/ui/src';
import { useGetPublicDashboardQuery } from 'app/features/dashboard/api/publicDashboardApi'; import { useGetPublicDashboardQuery } from 'app/features/dashboard/api/publicDashboardApi';
import { publicDashboardPersisted } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { publicDashboardPersisted } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { ShareModalTabProps } from 'app/features/dashboard/components/ShareModal/types'; import { ShareModalTabProps } from 'app/features/dashboard/components/ShareModal/types';
import { useSelector } from 'app/types';
import { HorizontalGroup } from '../../../../plugins/admin/components/HorizontalGroup'; import { HorizontalGroup } from '../../../../plugins/admin/components/HorizontalGroup';
import ConfigPublicDashboard from './ConfigPublicDashboard/ConfigPublicDashboard'; import { ConfigPublicDashboard } from './ConfigPublicDashboard/ConfigPublicDashboard';
import CreatePublicDashboard from './CreatePublicDashboard/CreatePublicDashboard'; import { CreatePublicDashboard } from './CreatePublicDashboard/CreatePublicDashboard';
import { useGetUnsupportedDataSources } from './useGetUnsupportedDataSources';
interface Props extends ShareModalTabProps {} interface Props extends ShareModalTabProps {}
const Loader = () => { export const Loader = () => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
@ -28,28 +31,31 @@ const Loader = () => {
export const SharePublicDashboard = (props: Props) => { export const SharePublicDashboard = (props: Props) => {
const { data: publicDashboard, isLoading, isError } = useGetPublicDashboardQuery(props.dashboard.uid); const { data: publicDashboard, isLoading, isError } = useGetPublicDashboardQuery(props.dashboard.uid);
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const { unsupportedDataSources } = useGetUnsupportedDataSources(dashboard);
return ( return (
<> <>
{isLoading ? ( {isLoading ? (
<Loader /> <Loader />
) : !publicDashboardPersisted(publicDashboard) ? ( ) : !publicDashboardPersisted(publicDashboard) ? (
<CreatePublicDashboard isError={isError} /> <CreatePublicDashboard hasError={isError} />
) : ( ) : (
<ConfigPublicDashboard /> <ConfigPublicDashboard publicDashboard={publicDashboard!} unsupportedDatasources={unsupportedDataSources} />
)} )}
</> </>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
loadingContainer: css` loadingContainer: css({
height: 280px; height: '280px',
align-items: center; alignItems: 'center',
justify-content: center; justifyContent: 'center',
gap: ${theme.spacing(1)}; gap: theme.spacing(1),
`, }),
spinner: css` spinner: css({
margin-bottom: ${theme.spacing(0)}; marginBottom: theme.spacing(0),
`, }),
}); });

View File

@ -1178,6 +1178,7 @@
"library-panel": "Bibliotheks-Panel", "library-panel": "Bibliotheks-Panel",
"link": "Link", "link": "Link",
"panel-embed": "", "panel-embed": "",
"public-dashboard": "",
"snapshot": "Schnappschuss" "snapshot": "Schnappschuss"
}, },
"theme-picker": { "theme-picker": {

View File

@ -1178,6 +1178,7 @@
"library-panel": "Library panel", "library-panel": "Library panel",
"link": "Link", "link": "Link",
"panel-embed": "Embed", "panel-embed": "Embed",
"public-dashboard": "Public Dashboard",
"snapshot": "Snapshot" "snapshot": "Snapshot"
}, },
"theme-picker": { "theme-picker": {

View File

@ -1184,6 +1184,7 @@
"library-panel": "Panel de librería", "library-panel": "Panel de librería",
"link": "Enlace", "link": "Enlace",
"panel-embed": "", "panel-embed": "",
"public-dashboard": "",
"snapshot": "Instantánea" "snapshot": "Instantánea"
}, },
"theme-picker": { "theme-picker": {

View File

@ -1184,6 +1184,7 @@
"library-panel": "Panneau de bibliothèque", "library-panel": "Panneau de bibliothèque",
"link": "Lien", "link": "Lien",
"panel-embed": "", "panel-embed": "",
"public-dashboard": "",
"snapshot": "Instantané" "snapshot": "Instantané"
}, },
"theme-picker": { "theme-picker": {

View File

@ -1178,6 +1178,7 @@
"library-panel": "Ŀįþřäřy päʼnęľ", "library-panel": "Ŀįþřäřy päʼnęľ",
"link": "Ŀįʼnĸ", "link": "Ŀįʼnĸ",
"panel-embed": "Ēmþęđ", "panel-embed": "Ēmþęđ",
"public-dashboard": "Pūþľįč Đäşĥþőäřđ",
"snapshot": "Ŝʼnäpşĥőŧ" "snapshot": "Ŝʼnäpşĥőŧ"
}, },
"theme-picker": { "theme-picker": {

View File

@ -1172,6 +1172,7 @@
"library-panel": "库面板", "library-panel": "库面板",
"link": "链接", "link": "链接",
"panel-embed": "", "panel-embed": "",
"public-dashboard": "",
"snapshot": "快照" "snapshot": "快照"
}, },
"theme-picker": { "theme-picker": {