mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
edb7d0e0d8
commit
1110cb4d44
@ -54,10 +54,14 @@ e2e.scenario({
|
||||
// These elements should be rendered
|
||||
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().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.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.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.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
|
||||
e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput()
|
||||
|
@ -213,6 +213,7 @@ export const Pages = {
|
||||
DeleteButton: 'data-testid public dashboard delete button',
|
||||
CopyUrlInput: 'data-testid public dashboard copy url input',
|
||||
CopyUrlButton: 'data-testid public dashboard copy url button',
|
||||
SettingsDropdown: 'data-testid public dashboard settings dropdown',
|
||||
TemplateVariablesWarningAlert: 'data-testid public dashboard disabled template variables alert',
|
||||
UnsupportedDataSourcesWarningAlert: 'data-testid public dashboard unsupported data sources alert',
|
||||
NoUpsertPermissionsWarningAlert: 'data-testid public dashboard no upsert permissions alert',
|
||||
|
@ -125,6 +125,7 @@ const getStyles = (
|
||||
|
||||
return {
|
||||
alert: css({
|
||||
label: 'alert',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
borderRadius,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
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 { stylesFactory } from '../../themes';
|
||||
@ -10,13 +10,10 @@ import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
|
||||
import { TimePickerButtonLabel } from './TimeRangePicker';
|
||||
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
|
||||
import { TimeRangeLabel } from './TimeRangePicker/TimeRangeLabel';
|
||||
import { quickOptions } from './options';
|
||||
|
||||
const isValidTimeRange = (range: TimeRange) => {
|
||||
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
|
||||
};
|
||||
import { isValidTimeRange } from './utils';
|
||||
|
||||
export interface TimeRangeInputProps {
|
||||
value: TimeRange;
|
||||
@ -87,11 +84,8 @@ export const TimeRangeInput = ({
|
||||
onClick={onOpen}
|
||||
>
|
||||
{showIcon && <Icon name="clock-nine" size={'sm'} className={styles.icon} />}
|
||||
{isValidTimeRange(value) ? (
|
||||
<TimePickerButtonLabel value={value} timeZone={timeZone} />
|
||||
) : (
|
||||
<span className={styles.placeholder}>{placeholder}</span>
|
||||
)}
|
||||
|
||||
<TimeRangeLabel value={value} timeZone={timeZone} placeholder={placeholder} />
|
||||
|
||||
{!disabled && (
|
||||
<span className={styles.caretIcon}>
|
||||
|
@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
@ -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 {
|
||||
if (isDateTime(value)) {
|
||||
@ -12,3 +12,7 @@ export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone):
|
||||
const parsed = dateTimeParse(value, { roundUp, timeZone });
|
||||
return parsed.isValid();
|
||||
}
|
||||
|
||||
export function isValidTimeRange(range: TimeRange) {
|
||||
return dateMath.isValid(range.from) && dateMath.isValid(range.to);
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export const Spinner = ({ className, inline = false, iconClassName, style, size
|
||||
const styles = getStyles(size, inline);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -35,6 +35,7 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
|
||||
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
|
||||
export { TimeRangePicker, type TimeRangePickerProps } from './DateTimePickers/TimeRangePicker';
|
||||
export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker';
|
||||
export { TimeRangeLabel } from './DateTimePickers/TimeRangePicker/TimeRangeLabel';
|
||||
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
|
||||
export { TimeZonePicker } from './DateTimePickers/TimeZonePicker';
|
||||
export { WeekStartPicker } from './DateTimePickers/WeekStartPicker';
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
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 { contextSrv } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@ -63,7 +65,7 @@ function getTabs(panel?: PanelModel, activeTab?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
interface Props extends Themeable2 {
|
||||
dashboard: DashboardModel;
|
||||
panel?: PanelModel;
|
||||
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) {
|
||||
super(props);
|
||||
this.state = getInitialState(props);
|
||||
@ -122,12 +124,19 @@ export class ShareModal extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboard, panel } = this.props;
|
||||
const { dashboard, panel, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
const activeTabModel = this.getActiveTab();
|
||||
const ActiveTab = activeTabModel.component;
|
||||
|
||||
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>
|
||||
<ActiveTab dashboard={dashboard} panel={panel} onDismiss={this.props.onDismiss} />
|
||||
</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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import cx from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@ -13,11 +12,11 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
ModalsContext,
|
||||
Spinner,
|
||||
Switch,
|
||||
useStyles2,
|
||||
} from '@grafana/ui/src';
|
||||
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 { AccessControlAction, useSelector } from '../../../../../../types';
|
||||
@ -38,6 +37,8 @@ import {
|
||||
|
||||
import { Configuration } from './Configuration';
|
||||
import { EmailSharingConfiguration } from './EmailSharingConfiguration';
|
||||
import { SettingsBar } from './SettingsBar';
|
||||
import { SettingsSummary } from './SettingsSummary';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
|
||||
|
||||
@ -49,8 +50,8 @@ export interface ConfigPublicDashboardForm {
|
||||
|
||||
const ConfigPublicDashboard = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { showModal, hideModal } = useContext(ModalsContext);
|
||||
const isDesktop = useIsDesktop();
|
||||
const { showModal, hideModal } = useContext(ModalsContext);
|
||||
|
||||
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
|
||||
const hasEmailSharingEnabled =
|
||||
@ -62,7 +63,9 @@ const ConfigPublicDashboard = () => {
|
||||
|
||||
const { data: publicDashboard, isFetching: isGetLoading } = useGetPublicDashboardQuery(dashboard.uid);
|
||||
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>({
|
||||
defaultValues: {
|
||||
@ -102,23 +105,17 @@ const ConfigPublicDashboard = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.configContainer}>
|
||||
{hasWritePermissions && dashboard.hasUnsavedChanges() && <SaveDashboardChangesAlert />}
|
||||
{!hasWritePermissions && <NoUpsertPermissionsAlert mode="edit" />}
|
||||
{dashboardHasTemplateVariables(dashboardVariables) && <UnsupportedTemplateVariablesAlert />}
|
||||
{!!unsupportedDataSources.length && (
|
||||
<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 />}
|
||||
<Field label="Dashboard URL" className={styles.publicUrl}>
|
||||
|
||||
<Field label="Dashboard URL" className={styles.fieldSpace}>
|
||||
<Input
|
||||
value={generatePublicDashboardUrl(publicDashboard!.accessToken!)}
|
||||
readOnly
|
||||
@ -136,12 +133,9 @@ const ConfigPublicDashboard = () => {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Layout
|
||||
orientation={isDesktop ? 0 : 1}
|
||||
justify={isDesktop ? 'flex-end' : 'flex-start'}
|
||||
align={isDesktop ? 'center' : 'normal'}
|
||||
>
|
||||
<HorizontalGroup spacing="sm">
|
||||
|
||||
<Field className={styles.fieldSpace}>
|
||||
<Layout>
|
||||
<Switch
|
||||
{...register('isPaused')}
|
||||
disabled={disableInputs}
|
||||
@ -160,10 +154,34 @@ const ConfigPublicDashboard = () => {
|
||||
>
|
||||
Pause sharing dashboard
|
||||
</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">
|
||||
<DeletePublicDashboardButton
|
||||
className={cx(styles.deleteButton, { [styles.deleteButtonMobile]: !isDesktop })}
|
||||
type="button"
|
||||
disabled={disableInputs}
|
||||
data-testid={selectors.DeleteButton}
|
||||
@ -186,23 +204,21 @@ const ConfigPublicDashboard = () => {
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
titleContainer: css`
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
configContainer: css`
|
||||
label: config container;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(3)};
|
||||
`,
|
||||
title: css`
|
||||
margin: 0;
|
||||
`,
|
||||
publicUrl: css`
|
||||
fieldSpace: css`
|
||||
label: field space;
|
||||
width: 100%;
|
||||
padding-top: ${theme.spacing(1)};
|
||||
margin-bottom: ${theme.spacing(3)};
|
||||
`,
|
||||
deleteButton: css`
|
||||
margin-left: ${theme.spacing(3)};
|
||||
`,
|
||||
deleteButtonMobile: css`
|
||||
margin-top: ${theme.spacing(2)};
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
timeRange: css({
|
||||
display: 'inline-block',
|
||||
}),
|
||||
});
|
||||
|
||||
export default ConfigPublicDashboard;
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
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 { 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 { getTimeRange } from 'app/features/dashboard/utils/timeRange';
|
||||
|
||||
import { useSelector } from '../../../../../../types';
|
||||
|
||||
import { ConfigPublicDashboardForm } from './ConfigPublicDashboard';
|
||||
|
||||
@ -19,21 +15,16 @@ export const Configuration = ({
|
||||
disabled,
|
||||
onChange,
|
||||
register,
|
||||
timeRange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onChange: (name: keyof ConfigPublicDashboardForm, value: boolean) => void;
|
||||
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 (
|
||||
<>
|
||||
<FieldSet disabled={disabled} className={styles.dashboardConfig}>
|
||||
<FieldSet disabled={disabled}>
|
||||
<VerticalGroup spacing="md">
|
||||
<Layout orientation={1} spacing="xs" justify="space-between">
|
||||
<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)};
|
||||
`,
|
||||
});
|
||||
|
@ -154,7 +154,7 @@ export const EmailSharingConfiguration = () => {
|
||||
|
||||
return (
|
||||
<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
|
||||
name="shareType"
|
||||
control={control}
|
||||
@ -184,6 +184,7 @@ export const EmailSharingConfiguration = () => {
|
||||
description="Invite people by email"
|
||||
error={errors.email?.message}
|
||||
invalid={!!errors.email?.message || undefined}
|
||||
className={styles.field}
|
||||
>
|
||||
<div className={styles.emailContainer}>
|
||||
<Input
|
||||
@ -221,20 +222,30 @@ export const EmailSharingConfiguration = () => {
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
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`
|
||||
label: emailContainer;
|
||||
display: flex;
|
||||
gap: ${theme.spacing(1)};
|
||||
`,
|
||||
emailInput: css`
|
||||
label: emailInput;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
table: css`
|
||||
label: table;
|
||||
display: flex;
|
||||
max-height: 220px;
|
||||
overflow-y: scroll;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
|
||||
& tbody {
|
||||
display: flex;
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
@ -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',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
@ -10,6 +10,7 @@ export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' })
|
||||
severity="info"
|
||||
title={`You don’t have permission to ${mode} a public dashboard`}
|
||||
data-testid={selectors.NoUpsertPermissionsWarningAlert}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
Contact your admin to get permission to {mode} create public dashboards
|
||||
</Alert>
|
||||
|
@ -3,5 +3,9 @@ import React from 'react';
|
||||
import { Alert } from '@grafana/ui/src';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
@ -16,6 +16,7 @@ export const UnsupportedDataSourcesAlert = ({ unsupportedDataSources }: { unsupp
|
||||
severity="warning"
|
||||
title="Unsupported data sources"
|
||||
data-testid={selectors.UnsupportedDataSourcesWarningAlert}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<p className={styles.unsupportedDataSourceDescription}>
|
||||
There are data sources in this dashboard that are unsupported for public dashboards. Panels that use these data
|
||||
|
@ -10,6 +10,7 @@ export const UnsupportedTemplateVariablesAlert = () => (
|
||||
severity="warning"
|
||||
title="Template variables are not supported"
|
||||
data-testid={selectors.TemplateVariablesWarningAlert}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
This public dashboard may not work since it uses template variables
|
||||
</Alert>
|
||||
|
@ -135,15 +135,28 @@ describe('SharePublic', () => {
|
||||
expect(screen.getByRole('tablist')).toHaveTextContent('Link');
|
||||
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' });
|
||||
|
||||
//@ts-ignore
|
||||
mockDashboard.originalTime = { from: 'now-6h', to: 'now' };
|
||||
|
||||
await renderSharePublicDashboard();
|
||||
await waitFor(() => screen.getByText('Time range ='));
|
||||
|
||||
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 () => {
|
||||
server.use(getNonExistentPublicDashboardResponse());
|
||||
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 () => {
|
||||
await renderSharePublicDashboard();
|
||||
await userEvent.click(screen.getByText('Settings'));
|
||||
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
await renderSharePublicDashboard();
|
||||
await userEvent.click(screen.getByText('Settings'));
|
||||
|
||||
expect(await screen.findByTestId(selectors.EnableTimeRangeSwitch)).toBeDisabled();
|
||||
expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeChecked();
|
||||
@ -257,6 +272,7 @@ describe('SharePublic - Already persisted', () => {
|
||||
);
|
||||
|
||||
await renderSharePublicDashboard();
|
||||
await userEvent.click(screen.getByText('Settings'));
|
||||
|
||||
const enableTimeRangeSwitch = await screen.findByTestId(selectors.EnableTimeRangeSwitch);
|
||||
await waitFor(() => {
|
||||
@ -320,6 +336,8 @@ describe('SharePublic - Report interactions', () => {
|
||||
|
||||
it('reports interaction when time range is clicked', async () => {
|
||||
await renderSharePublicDashboard();
|
||||
await userEvent.click(screen.getByText('Settings'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(selectors.EnableTimeRangeSwitch)).toBeEnabled();
|
||||
});
|
||||
@ -333,6 +351,8 @@ describe('SharePublic - Report interactions', () => {
|
||||
});
|
||||
it('reports interaction when show annotations is clicked', async () => {
|
||||
await renderSharePublicDashboard();
|
||||
await userEvent.click(screen.getByText('Settings'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeEnabled();
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user