Public Dashboard: Redesign modal (v2) (#71151)

* Update public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBar.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* revert modal styling and add specific styling to Sharing

* Update public/app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/SettingsBar.tsx

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>

* functions > const

* put a gat between all items in email config, instead of margins for each item

* fix html semantic elements

* ad theme to class component ShareModal

* add labels

* fix failing tests; now Settings has a summary and has to be opened to be able to see the On/Off toggles

* fix dashboard-public-create test with settings dropdown
This commit is contained in:
Polina Boneva 2023-07-25 13:17:39 +03:00 committed by GitHub
parent edb7d0e0d8
commit 1110cb4d44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 396 additions and 83 deletions

View File

@ -54,10 +54,14 @@ e2e.scenario({
// These elements should be rendered // These elements should be rendered
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().click();
// There elements should be rendered once the Settings dropdown is opened
e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist');
}, },
}); });
@ -91,10 +95,14 @@ e2e.scenario({
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist'); e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().click();
// There elements should be rendered once the Settings dropdown is opened
e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist');
e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist');
// Make a request to public dashboards api endpoint without authentication // Make a request to public dashboards api endpoint without authentication
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput() e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput()

View File

@ -213,6 +213,7 @@ export const Pages = {
DeleteButton: 'data-testid public dashboard delete button', DeleteButton: 'data-testid public dashboard delete button',
CopyUrlInput: 'data-testid public dashboard copy url input', CopyUrlInput: 'data-testid public dashboard copy url input',
CopyUrlButton: 'data-testid public dashboard copy url button', CopyUrlButton: 'data-testid public dashboard copy url button',
SettingsDropdown: 'data-testid public dashboard settings dropdown',
TemplateVariablesWarningAlert: 'data-testid public dashboard disabled template variables alert', TemplateVariablesWarningAlert: 'data-testid public dashboard disabled template variables alert',
UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert', UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert',
NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert', NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert',

View File

@ -125,6 +125,7 @@ const getStyles = (
return { return {
alert: css({ alert: css({
label: 'alert',
flexGrow: 1, flexGrow: 1,
position: 'relative', position: 'relative',
borderRadius, borderRadius,

View File

@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { FormEvent, MouseEvent, useState } from 'react'; import React, { FormEvent, MouseEvent, useState } from 'react';
import { dateMath, dateTime, getDefaultTimeRange, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data'; import { dateTime, getDefaultTimeRange, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { stylesFactory } from '../../themes'; import { stylesFactory } from '../../themes';
@ -10,13 +10,10 @@ import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { getInputStyles } from '../Input/Input'; import { getInputStyles } from '../Input/Input';
import { TimePickerButtonLabel } from './TimeRangePicker';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent'; import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { TimeRangeLabel } from './TimeRangePicker/TimeRangeLabel';
import { quickOptions } from './options'; import { quickOptions } from './options';
import { isValidTimeRange } from './utils';
const isValidTimeRange = (range: TimeRange) => {
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
};
export interface TimeRangeInputProps { export interface TimeRangeInputProps {
value: TimeRange; value: TimeRange;
@ -87,11 +84,8 @@ export const TimeRangeInput = ({
onClick={onOpen} onClick={onOpen}
> >
{showIcon && <Icon name="clock-nine" size={'sm'} className={styles.icon} />} {showIcon && <Icon name="clock-nine" size={'sm'} className={styles.icon} />}
{isValidTimeRange(value) ? (
<TimePickerButtonLabel value={value} timeZone={timeZone} /> <TimeRangeLabel value={value} timeZone={timeZone} placeholder={placeholder} />
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
{!disabled && ( {!disabled && (
<span className={styles.caretIcon}> <span className={styles.caretIcon}>

View File

@ -0,0 +1,46 @@
import { css } from '@emotion/css';
import React, { memo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../src/themes';
import { TimePickerButtonLabel, TimeRangePickerProps } from '../TimeRangePicker';
import { isValidTimeRange } from '../utils';
type LabelProps = Pick<TimeRangePickerProps, 'hideText' | 'value' | 'timeZone'> & {
placeholder?: string;
className?: string;
};
export const TimeRangeLabel = memo<LabelProps>(function TimePickerLabel({
hideText,
value,
timeZone = 'browser',
placeholder = 'No time range selected',
className,
}) {
const styles = useStyles2(getLabelStyles);
if (hideText) {
return null;
}
return (
<span className={className}>
{isValidTimeRange(value) ? (
<TimePickerButtonLabel value={value} timeZone={timeZone} />
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
</span>
);
});
const getLabelStyles = (theme: GrafanaTheme2) => {
return {
placeholder: css({
color: theme.colors.text.disabled,
opacity: 1,
}),
};
};

View File

@ -1,4 +1,4 @@
import { dateMath, dateTimeParse, isDateTime, TimeZone } from '@grafana/data'; import { dateMath, dateTimeParse, isDateTime, TimeRange, TimeZone } from '@grafana/data';
export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone): boolean { export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone): boolean {
if (isDateTime(value)) { if (isDateTime(value)) {
@ -12,3 +12,7 @@ export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone):
const parsed = dateTimeParse(value, { roundUp, timeZone }); const parsed = dateTimeParse(value, { roundUp, timeZone });
return parsed.isValid(); return parsed.isValid();
} }
export function isValidTimeRange(range: TimeRange) {
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
}

View File

@ -28,7 +28,7 @@ export const Spinner = ({ className, inline = false, iconClassName, style, size
const styles = getStyles(size, inline); const styles = getStyles(size, inline);
return ( return (
<div data-testid="Spinner" style={style} className={cx(styles, className)}> <div data-testid="Spinner" style={style} className={cx(styles, className)}>
<Icon className={cx('fa-spin', iconClassName)} name="fa fa-spinner" /> <Icon className={cx('fa-spin', iconClassName)} name="fa fa-spinner" aria-label="loading spinner" />
</div> </div>
); );
}; };

View File

@ -35,6 +35,7 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker'; export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
export { TimeRangePicker, type TimeRangePickerProps } from './DateTimePickers/TimeRangePicker'; export { TimeRangePicker, type TimeRangePickerProps } from './DateTimePickers/TimeRangePicker';
export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker'; export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker';
export { TimeRangeLabel } from './DateTimePickers/TimeRangePicker/TimeRangeLabel';
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker'; export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
export { TimeZonePicker } from './DateTimePickers/TimeZonePicker'; export { TimeZonePicker } from './DateTimePickers/TimeZonePicker';
export { WeekStartPicker } from './DateTimePickers/WeekStartPicker'; export { WeekStartPicker } from './DateTimePickers/WeekStartPicker';

View File

@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src'; import { reportInteraction } from '@grafana/runtime/src';
import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
@ -63,7 +65,7 @@ function getTabs(panel?: PanelModel, activeTab?: string) {
}; };
} }
interface Props { interface Props extends Themeable2 {
dashboard: DashboardModel; dashboard: DashboardModel;
panel?: PanelModel; panel?: PanelModel;
activeTab?: string; activeTab?: string;
@ -85,7 +87,7 @@ function getInitialState(props: Props): State {
}; };
} }
export class ShareModal extends React.Component<Props, State> { class UnthemedShareModal extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = getInitialState(props); this.state = getInitialState(props);
@ -122,12 +124,19 @@ export class ShareModal extends React.Component<Props, State> {
} }
render() { render() {
const { dashboard, panel } = this.props; const { dashboard, panel, theme } = this.props;
const styles = getStyles(theme);
const activeTabModel = this.getActiveTab(); const activeTabModel = this.getActiveTab();
const ActiveTab = activeTabModel.component; const ActiveTab = activeTabModel.component;
return ( return (
<Modal isOpen={true} title={this.renderTitle()} onDismiss={this.props.onDismiss}> <Modal
isOpen={true}
title={this.renderTitle()}
onDismiss={this.props.onDismiss}
className={styles.container}
contentClassName={styles.content}
>
<TabContent> <TabContent>
<ActiveTab dashboard={dashboard} panel={panel} onDismiss={this.props.onDismiss} /> <ActiveTab dashboard={dashboard} panel={panel} onDismiss={this.props.onDismiss} />
</TabContent> </TabContent>
@ -135,3 +144,18 @@ export class ShareModal extends React.Component<Props, State> {
); );
} }
} }
export const ShareModal = withTheme2(UnthemedShareModal);
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
label: 'shareModalContainer',
paddingTop: theme.spacing(1),
}),
content: css({
label: 'shareModalContent',
padding: theme.spacing(3, 2, 2, 2),
}),
};
};

View File

@ -1,5 +1,4 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import cx from 'classnames';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -13,11 +12,11 @@ import {
Input, Input,
Label, Label,
ModalsContext, ModalsContext,
Spinner,
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 { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { contextSrv } from '../../../../../../core/services/context_srv'; import { contextSrv } from '../../../../../../core/services/context_srv';
import { AccessControlAction, useSelector } from '../../../../../../types'; import { AccessControlAction, useSelector } from '../../../../../../types';
@ -38,6 +37,8 @@ import {
import { Configuration } from './Configuration'; import { Configuration } from './Configuration';
import { EmailSharingConfiguration } from './EmailSharingConfiguration'; import { EmailSharingConfiguration } from './EmailSharingConfiguration';
import { SettingsBar } from './SettingsBar';
import { SettingsSummary } from './SettingsSummary';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
@ -49,8 +50,8 @@ export interface ConfigPublicDashboardForm {
const ConfigPublicDashboard = () => { const ConfigPublicDashboard = () => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { showModal, hideModal } = useContext(ModalsContext);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const { showModal, hideModal } = useContext(ModalsContext);
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin()); const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
const hasEmailSharingEnabled = const hasEmailSharingEnabled =
@ -62,7 +63,9 @@ const ConfigPublicDashboard = () => {
const { data: publicDashboard, isFetching: isGetLoading } = useGetPublicDashboardQuery(dashboard.uid); const { data: publicDashboard, isFetching: isGetLoading } = useGetPublicDashboardQuery(dashboard.uid);
const [update, { isLoading: isUpdateLoading }] = useUpdatePublicDashboardMutation(); const [update, { isLoading: isUpdateLoading }] = useUpdatePublicDashboardMutation();
const disableInputs = !hasWritePermissions || isUpdateLoading || isGetLoading; 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: {
@ -102,23 +105,17 @@ const ConfigPublicDashboard = () => {
}; };
return ( return (
<div> <div className={styles.configContainer}>
{hasWritePermissions && dashboard.hasUnsavedChanges() && <SaveDashboardChangesAlert />} {hasWritePermissions && dashboard.hasUnsavedChanges() && <SaveDashboardChangesAlert />}
{!hasWritePermissions && <NoUpsertPermissionsAlert mode="edit" />} {!hasWritePermissions && <NoUpsertPermissionsAlert mode="edit" />}
{dashboardHasTemplateVariables(dashboardVariables) && <UnsupportedTemplateVariablesAlert />} {dashboardHasTemplateVariables(dashboardVariables) && <UnsupportedTemplateVariablesAlert />}
{!!unsupportedDataSources.length && ( {!!unsupportedDataSources.length && (
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} /> <UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} />
)} )}
<div className={styles.titleContainer}>
<HorizontalGroup spacing="sm" align="center">
<h4 className={styles.title}>Settings</h4>
{(isUpdateLoading || isGetLoading) && <Spinner size={14} />}
</HorizontalGroup>
</div>
<Configuration disabled={disableInputs} onChange={onChange} register={register} />
<hr />
{hasEmailSharingEnabled && <EmailSharingConfiguration />} {hasEmailSharingEnabled && <EmailSharingConfiguration />}
<Field label="Dashboard URL" className={styles.publicUrl}>
<Field label="Dashboard URL" className={styles.fieldSpace}>
<Input <Input
value={generatePublicDashboardUrl(publicDashboard!.accessToken!)} value={generatePublicDashboardUrl(publicDashboard!.accessToken!)}
readOnly readOnly
@ -136,12 +133,9 @@ const ConfigPublicDashboard = () => {
} }
/> />
</Field> </Field>
<Layout
orientation={isDesktop ? 0 : 1} <Field className={styles.fieldSpace}>
justify={isDesktop ? 'flex-end' : 'flex-start'} <Layout>
align={isDesktop ? 'center' : 'normal'}
>
<HorizontalGroup spacing="sm">
<Switch <Switch
{...register('isPaused')} {...register('isPaused')}
disabled={disableInputs} disabled={disableInputs}
@ -160,10 +154,34 @@ const ConfigPublicDashboard = () => {
> >
Pause sharing dashboard Pause sharing dashboard
</Label> </Label>
</HorizontalGroup> </Layout>
</Field>
<Field className={styles.fieldSpace}>
<SettingsBar
title="Settings"
headerElement={({ className }) => (
<SettingsSummary
className={className}
isDataLoading={isDataLoading}
timeRange={timeRange}
timeSelectionEnabled={publicDashboard?.timeSelectionEnabled}
annotationsEnabled={publicDashboard?.annotationsEnabled}
/>
)}
data-testid={selectors.SettingsDropdown}
>
<Configuration disabled={disableInputs} onChange={onChange} register={register} timeRange={timeRange} />
</SettingsBar>
</Field>
<Layout
orientation={isDesktop ? 0 : 1}
justify={isDesktop ? 'flex-end' : 'flex-start'}
align={isDesktop ? 'center' : 'normal'}
>
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">
<DeletePublicDashboardButton <DeletePublicDashboardButton
className={cx(styles.deleteButton, { [styles.deleteButtonMobile]: !isDesktop })}
type="button" type="button"
disabled={disableInputs} disabled={disableInputs}
data-testid={selectors.DeleteButton} data-testid={selectors.DeleteButton}
@ -186,23 +204,21 @@ const ConfigPublicDashboard = () => {
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
titleContainer: css` configContainer: css`
margin-bottom: ${theme.spacing(2)}; label: config container;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: ${theme.spacing(3)};
`, `,
title: css` fieldSpace: css`
margin: 0; label: field space;
`,
publicUrl: css`
width: 100%; width: 100%;
padding-top: ${theme.spacing(1)}; margin-bottom: 0;
margin-bottom: ${theme.spacing(3)};
`,
deleteButton: css`
margin-left: ${theme.spacing(3)};
`,
deleteButtonMobile: css`
margin-top: ${theme.spacing(2)};
`, `,
timeRange: css({
display: 'inline-block',
}),
}); });
export default ConfigPublicDashboard; export default ConfigPublicDashboard;

View File

@ -1,15 +1,11 @@
import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { UseFormRegister } from 'react-hook-form'; import { UseFormRegister } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data/src'; import { TimeRange } from '@grafana/data/src';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { reportInteraction } from '@grafana/runtime/src'; import { reportInteraction } from '@grafana/runtime/src';
import { FieldSet, Label, Switch, TimeRangeInput, useStyles2, VerticalGroup } from '@grafana/ui/src'; import { FieldSet, Label, Switch, TimeRangeInput, VerticalGroup } from '@grafana/ui/src';
import { Layout } from '@grafana/ui/src/components/Layout/Layout'; import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
import { useSelector } from '../../../../../../types';
import { ConfigPublicDashboardForm } from './ConfigPublicDashboard'; import { ConfigPublicDashboardForm } from './ConfigPublicDashboard';
@ -19,21 +15,16 @@ export const Configuration = ({
disabled, disabled,
onChange, onChange,
register, register,
timeRange,
}: { }: {
disabled: boolean; disabled: boolean;
onChange: (name: keyof ConfigPublicDashboardForm, value: boolean) => void; onChange: (name: keyof ConfigPublicDashboardForm, value: boolean) => void;
register: UseFormRegister<ConfigPublicDashboardForm>; register: UseFormRegister<ConfigPublicDashboardForm>;
timeRange: TimeRange;
}) => { }) => {
const styles = useStyles2(getStyles);
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel()!;
const timeRange = getTimeRange(dashboard.getDefaultTime(), dashboard);
return ( return (
<> <>
<FieldSet disabled={disabled} className={styles.dashboardConfig}> <FieldSet disabled={disabled}>
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
<Layout orientation={1} spacing="xs" justify="space-between"> <Layout orientation={1} spacing="xs" justify="space-between">
<Label description="The public dashboard uses the default time range settings of the dashboard"> <Label description="The public dashboard uses the default time range settings of the dashboard">
@ -72,9 +63,3 @@ export const Configuration = ({
</> </>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => ({
dashboardConfig: css`
margin: ${theme.spacing(0, 0, 3, 0)};
`,
});

View File

@ -154,7 +154,7 @@ export const EmailSharingConfiguration = () => {
return ( return (
<form data-testid={selectors.Container} className={styles.container} onSubmit={handleSubmit(onSubmit)}> <form data-testid={selectors.Container} className={styles.container} onSubmit={handleSubmit(onSubmit)}>
<Field label="Can view dashboard"> <Field label="Can view dashboard" className={styles.field}>
<InputControl <InputControl
name="shareType" name="shareType"
control={control} control={control}
@ -184,6 +184,7 @@ export const EmailSharingConfiguration = () => {
description="Invite people by email" description="Invite people by email"
error={errors.email?.message} error={errors.email?.message}
invalid={!!errors.email?.message || undefined} invalid={!!errors.email?.message || undefined}
className={styles.field}
> >
<div className={styles.emailContainer}> <div className={styles.emailContainer}>
<Input <Input
@ -221,20 +222,30 @@ export const EmailSharingConfiguration = () => {
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
container: css` container: css`
margin-bottom: ${theme.spacing(2)}; label: emailConfigContainer;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: ${theme.spacing(3)};
`,
field: css`
label: field-noMargin;
margin-bottom: 0;
`, `,
emailContainer: css` emailContainer: css`
label: emailContainer;
display: flex; display: flex;
gap: ${theme.spacing(1)}; gap: ${theme.spacing(1)};
`, `,
emailInput: css` emailInput: css`
label: emailInput;
flex-grow: 1; flex-grow: 1;
`, `,
table: css` table: css`
label: table;
display: flex; display: flex;
max-height: 220px; max-height: 220px;
overflow-y: scroll; overflow-y: scroll;
margin-bottom: ${theme.spacing(1)};
& tbody { & tbody {
display: flex; display: flex;

View File

@ -0,0 +1,44 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { SettingsBarHeader, Props as SettingsBarHeaderProps } from './SettingsBarHeader';
export interface Props extends Pick<SettingsBarHeaderProps, 'headerElement' | 'title'> {
children: React.ReactNode;
}
export function SettingsBar({ children, title, headerElement, ...rest }: Props) {
const styles = useStyles2(getStyles);
const [isContentVisible, setIsContentVisible] = useState(false);
function onRowToggle() {
setIsContentVisible((prevState) => !prevState);
}
return (
<>
<SettingsBarHeader
onRowToggle={onRowToggle}
isContentVisible={isContentVisible}
title={title}
headerElement={headerElement}
{...rest}
/>
{isContentVisible && <div className={styles.content}>{children}</div>}
</>
);
}
SettingsBar.displayName = 'SettingsBar';
const getStyles = (theme: GrafanaTheme2) => {
return {
content: css({
marginTop: theme.spacing(1),
marginLeft: theme.spacing(4),
}),
};
};

View File

@ -0,0 +1,94 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, ReactUtils, useStyles2 } from '@grafana/ui';
export interface Props {
onRowToggle: () => void;
isContentVisible?: boolean;
title?: string;
headerElement?: React.ReactNode | ((props: { className?: string }) => React.ReactNode);
}
export function SettingsBarHeader({ headerElement, isContentVisible = false, onRowToggle, title, ...rest }: Props) {
const styles = useStyles2(getStyles);
const headerElementRendered =
headerElement && ReactUtils.renderOrCallToRender(headerElement, { className: styles.summaryWrapper });
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<IconButton
name={isContentVisible ? 'angle-down' : 'angle-right'}
tooltip={isContentVisible ? 'Collapse settings' : 'Expand settings'}
className={styles.collapseIcon}
onClick={onRowToggle}
aria-expanded={isContentVisible}
{...rest}
/>
{title && (
// disabling the a11y rules here as the IconButton above handles keyboard interactions
// this is just to provide a better experience for mouse users
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className={styles.titleWrapper} onClick={onRowToggle}>
<span className={styles.title}>{title}</span>
</div>
)}
{headerElementRendered}
</div>
</div>
);
}
SettingsBarHeader.displayName = 'SettingsBarHeader';
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
label: 'header',
padding: theme.spacing(0.5, 0.5),
borderRadius: theme.shape.borderRadius(1),
background: theme.colors.background.secondary,
minHeight: theme.spacing(4),
'&:focus': {
outline: 'none',
},
}),
header: css({
label: 'column',
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
}),
collapseIcon: css({
marginLeft: theme.spacing(0.5),
color: theme.colors.text.disabled,
}),
titleWrapper: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
overflow: 'hidden',
marginRight: `${theme.spacing(0.5)}`,
[theme.breakpoints.down('sm')]: {
flex: '1 1',
},
}),
title: css({
fontWeight: theme.typography.fontWeightBold,
marginLeft: theme.spacing(0.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
summaryWrapper: css({
display: 'flex',
flexWrap: 'wrap',
[theme.breakpoints.down('sm')]: {
flex: '2 2',
},
}),
};
}

View File

@ -0,0 +1,57 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { Spinner, TimeRangeLabel, useStyles2 } from '@grafana/ui';
export interface Props {
timeRange: TimeRange;
className?: string;
isDataLoading?: boolean;
timeSelectionEnabled?: boolean;
annotationsEnabled?: boolean;
}
export function SettingsSummary({
className,
isDataLoading = false,
timeRange,
timeSelectionEnabled,
annotationsEnabled,
}: Props) {
const styles = useStyles2(getStyles);
return isDataLoading ? (
<div className={cx(styles.summaryWrapper, className)}>
<Spinner className={styles.summary} inline={true} size={14} />
</div>
) : (
<div className={cx(styles.summaryWrapper, className)}>
<span className={styles.summary}>
{'Time range = '}
<TimeRangeLabel className={styles.timeRange} value={timeRange} />
</span>
<span className={styles.summary}>{`Time range picker = ${timeSelectionEnabled ? 'enabled' : 'disabled'}`}</span>
<span className={styles.summary}>{`Annotations = ${annotationsEnabled ? 'show' : 'hide'}`}</span>
</div>
);
}
SettingsSummary.displayName = 'SettingsSummary';
const getStyles = (theme: GrafanaTheme2) => {
return {
summaryWrapper: css({
display: 'flex',
}),
summary: css`
label: collapsedText;
margin-left: ${theme.spacing.gridSize * 2}px;
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
`,
timeRange: css({
display: 'inline-block',
}),
};
};

View File

@ -10,6 +10,7 @@ export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' })
severity="info" severity="info"
title={`You dont have permission to ${mode} a public dashboard`} title={`You dont have permission to ${mode} a public dashboard`}
data-testid={selectors.NoUpsertPermissionsWarningAlert} data-testid={selectors.NoUpsertPermissionsWarningAlert}
bottomSpacing={0}
> >
Contact your admin to get permission to {mode} create public dashboards Contact your admin to get permission to {mode} create public dashboards
</Alert> </Alert>

View File

@ -3,5 +3,9 @@ import React from 'react';
import { Alert } from '@grafana/ui/src'; import { Alert } from '@grafana/ui/src';
export const SaveDashboardChangesAlert = () => ( export const SaveDashboardChangesAlert = () => (
<Alert title="Please save your dashboard changes before updating the public configuration" severity="warning" /> <Alert
title="Please save your dashboard changes before updating the public configuration"
severity="warning"
bottomSpacing={0}
/>
); );

View File

@ -16,6 +16,7 @@ export const UnsupportedDataSourcesAlert = ({ unsupportedDataSources }: { unsupp
severity="warning" severity="warning"
title="Unsupported data sources" title="Unsupported data sources"
data-testid={selectors.UnsupportedDataSourcesWarningAlert} data-testid={selectors.UnsupportedDataSourcesWarningAlert}
bottomSpacing={0}
> >
<p className={styles.unsupportedDataSourceDescription}> <p className={styles.unsupportedDataSourceDescription}>
There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these data There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these data

View File

@ -10,6 +10,7 @@ export const UnsupportedTemplateVariablesAlert = () => (
severity="warning" severity="warning"
title="Template variables are not supported" title="Template variables are not supported"
data-testid={selectors.TemplateVariablesWarningAlert} data-testid={selectors.TemplateVariablesWarningAlert}
bottomSpacing={0}
> >
This public dashboard may not work since it uses template variables This public dashboard may not work since it uses template variables
</Alert> </Alert>

View File

@ -135,15 +135,28 @@ describe('SharePublic', () => {
expect(screen.getByRole('tablist')).toHaveTextContent('Link'); expect(screen.getByRole('tablist')).toHaveTextContent('Link');
expect(screen.getByRole('tablist')).not.toHaveTextContent('Public dashboard'); expect(screen.getByRole('tablist')).not.toHaveTextContent('Public dashboard');
}); });
it('renders default relative time in input', async () => { it('renders default relative time in settings summary when they are closed', async () => {
expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' }); expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' });
//@ts-ignore //@ts-ignore
mockDashboard.originalTime = { from: 'now-6h', to: 'now' }; mockDashboard.originalTime = { from: 'now-6h', to: 'now' };
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await waitFor(() => screen.getByText('Time range ='));
expect(screen.getByText('Last 6 hours')).toBeInTheDocument(); expect(screen.getByText('Last 6 hours')).toBeInTheDocument();
}); });
it('renders default relative time in settings when they are open', async () => {
expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' });
//@ts-ignore
mockDashboard.originalTime = { from: 'now-6h', to: 'now' };
await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
expect(screen.queryAllByText('Last 6 hours')).toHaveLength(2);
});
it('when modal is opened, then checkboxes are enabled but create button is disabled', async () => { it('when modal is opened, then checkboxes are enabled but create button is disabled', async () => {
server.use(getNonExistentPublicDashboardResponse()); server.use(getNonExistentPublicDashboardResponse());
await renderSharePublicDashboard(); await renderSharePublicDashboard();
@ -214,6 +227,7 @@ describe('SharePublic - Already persisted', () => {
}); });
it('when fetch is done, then inputs are checked and delete button is enabled', async () => { it('when fetch is done, then inputs are checked and delete button is enabled', async () => {
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeEnabled();
@ -231,6 +245,7 @@ describe('SharePublic - Already persisted', () => {
it('inputs and delete button are disabled because of lack of permissions', async () => { it('inputs and delete button are disabled because of lack of permissions', async () => {
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false); jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
expect(await screen.findByTestId(selectors.EnableTimeRangeSwitch)).toBeDisabled(); expect(await screen.findByTestId(selectors.EnableTimeRangeSwitch)).toBeDisabled();
expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeChecked(); expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeChecked();
@ -257,6 +272,7 @@ describe('SharePublic - Already persisted', () => {
); );
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
const enableTimeRangeSwitch = await screen.findByTestId(selectors.EnableTimeRangeSwitch); const enableTimeRangeSwitch = await screen.findByTestId(selectors.EnableTimeRangeSwitch);
await waitFor(() => { await waitFor(() => {
@ -320,6 +336,8 @@ describe('SharePublic - Report interactions', () => {
it('reports interaction when time range is clicked', async () => { it('reports interaction when time range is clicked', async () => {
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeEnabled();
}); });
@ -333,6 +351,8 @@ describe('SharePublic - Report interactions', () => {
}); });
it('reports interaction when show annotations is clicked', async () => { it('reports interaction when show annotations is clicked', async () => {
await renderSharePublicDashboard(); await renderSharePublicDashboard();
await userEvent.click(screen.getByText('Settings'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeEnabled();
}); });