Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): add ShareTimeRangeButton component #156

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/components/ShareTimeRangeButton/ShareTimeRangeButton.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Meta, ArgTypes } from '@storybook/blocks';
import { ShareTimeRangeButton } from './ShareTimeRangeButton';

<Meta title="MDX|ShareTimeRangeButton" component={ShareTimeRangeButton} />

# ShareTimeRangeButton

ShareTimeRangeButton is a component that provides URL sharing functionality with time range preservation for use cases where
time range info is stored in the URL as a query parameter such as "from" and "to".

It consists of a button group with a primary share button and a dropdown menu offering different URL copying options as
well as options for specifying which query parameters should be used.

When copying URLs:

- If no time range is present, it defaults to the last 30 minutes
- Relative time ranges (e.g., 'now-6h') are converted to absolute timestamps
- Absolute time ranges are preserved as-is

# Usage

```jsx
<ShareTimeRangeButton />
```

```jsx
<ShareTimeRangeButton collapsed={true} />
```

<ArgTypes of={ShareTimeRangeButton} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type StoryFn, type Meta } from '@storybook/react';

import { ShareTimeRangeButton as ShareTimeRangeButtonImpl, type Props } from './ShareTimeRangeButton';
import mdx from './ShareTimeRangeButton.mdx';

const meta: Meta = {
title: 'Buttons/ShareTimeRangeButton',
component: ShareTimeRangeButtonImpl,
args: {
url: 'http://mygrafanainstance.grafana.net/dashboard/1?from=now-1h&to=now',
fromParam: 'from',
toParam: 'to',
},
parameters: {
docs: {
page: mdx,
},
controls: {
exclude: [
'fill',
'type',
'tooltip',
'tooltipPlacement',
'size',
'variant',
'icon',
'className',
'fullWidth',
'getText',
'onClipboardCopy',
'onClipboardError',
],
},
},
};

interface StoryProps extends Props {}

export const ShareTimeRangeButton: StoryFn<StoryProps> = (args) => {
return <ShareTimeRangeButtonImpl {...args} />;

Check failure on line 40 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.story.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
};

export default meta;
110 changes: 110 additions & 0 deletions src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { css } from '@emotion/css';
import { useRef, useState } from 'react';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { useRef, useState } from 'react';
import React, { useRef, useState } from 'react';


import { type GrafanaTheme2 } from '@grafana/data';

import { useStyles2 } from '../../themes';
import { copyText } from '../../utils/clipboard';
import { t, Trans } from '../../utils/i18n';
import { absoluteTimeRangeURL } from '../../utils/time';
import { Button, ButtonGroup, type ButtonProps } from '../Button';
import { ClipboardButton } from '../ClipboardButton/ClipboardButton';
import { Dropdown } from '../Dropdown/Dropdown';
import { Menu } from '../Menu/Menu';
import { type MenuItemElement } from '../Menu/MenuItem';
Comment on lines +6 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These all are no longer accessible through relative path and you need to import them from @grafana/ui


export interface Props extends ButtonProps {
/**
* Whether to collapse the button text
*/
collapsed?: boolean;

/**
* The URL to share
*/
url?: string;

/**
* The from parameter to use in the URL
*
* @default 'from'
*/
fromParam?: string;

/**
* The to parameter to use in the URL
*
* @default 'to'
*/
toParam?: string;
}

export function ShareTimeRangeButton({ collapsed, url: urlProp, fromParam, toParam }: Props) {
const [isOpen, setIsOpen] = useState(false);
const styles = useStyles2(getStyles);
const url = urlProp ?? window.location.href;

const relativeUrlRef = useRef<MenuItemElement>(null);
const absoluteUrlRef = useRef<MenuItemElement>(null);

const clickHandler = (text: string, ref: React.RefObject<MenuItemElement>) => {
copyText(text, ref);
setIsOpen(false);
};

const menu = (
<Menu>

Check failure on line 56 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
<Menu.Item

Check failure on line 57 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
key="copy-url-relative"
label={t('grafana-ui.toolbar.copy-link', 'Copy URL')}
icon="link"
onClick={() => clickHandler(url, relativeUrlRef)}
ref={relativeUrlRef}
/>
<Menu.Item

Check failure on line 64 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
key="copy-url"
label={t('grafana-ui.toolbar.copy-link-abs-time', 'Copy absolute URL')}
icon="clock-nine"
onClick={() => clickHandler(absoluteTimeRangeURL({ url, fromParam, toParam }), absoluteUrlRef)}
ref={absoluteUrlRef}
/>
</Menu>
);

return (
<ButtonGroup>

Check failure on line 75 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
<ClipboardButton

Check failure on line 76 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
className={styles.copy}
variant="secondary"
size="md"
icon="share-alt"
tooltip={t('grafana-ui.toolbar.copy-link-abs-time', 'Copy absolute URL')}
getText={() => absoluteTimeRangeURL({ url, fromParam, toParam })}
>
<span className={collapsed ? styles.collapsed : styles.shareText}>

Check failure on line 84 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
<Trans i18nKey="grafana-ui.toolbar.copy-shortened-link-label">Share</Trans>

Check failure on line 85 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
</span>
</ClipboardButton>
<Dropdown overlay={menu} placement="bottom-start" onVisibleChange={() => setIsOpen(!isOpen)}>

Check failure on line 88 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
<Button variant="secondary" size="md" icon={isOpen ? 'angle-up' : 'angle-down'} />

Check failure on line 89 in src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx

View workflow job for this annotation

GitHub Actions / test-and-build

'React' must be in scope when using JSX
</Dropdown>
</ButtonGroup>
);
}

const getStyles = (theme: GrafanaTheme2) => ({
copy: css({
marginRight: `${theme.spacing(0)}`,
padding: `${theme.spacing(0, 1)}`,
svg: css({
marginRight: `${theme.spacing(0)}`,
}),
}),
collapsed: css({
marginLeft: `${theme.spacing(1)}`,
display: 'none',
}),
shareText: css({
marginLeft: `${theme.spacing(1)}`,
}),
});
159 changes: 159 additions & 0 deletions src/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { toUtc, rangeUtil } from '@grafana/data';

import { absoluteTimeRangeURL } from './time';

describe('absoluteTimeRangeURL', () => {
const fakeSystemTime = new Date('2024-01-01T00:00:00Z').getTime();

beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(fakeSystemTime);
});

afterAll(() => {
jest.useRealTimers();
});

it('should return URL with default 30min time range when no time params present', () => {
const url = 'http://localhost:3000/dashboard';
const result = absoluteTimeRangeURL({ url });

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 30 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(`http://localhost:3000/dashboard?to=${expectedTo}&from=${expectedFrom}`);
});

it('should convert relative time range to absolute', () => {
const url = 'http://localhost:3000/dashboard?from=now-6h&to=now';
const result = absoluteTimeRangeURL({ url });

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 6 * 60 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(`http://localhost:3000/dashboard?from=${expectedFrom}&to=${expectedTo}`);
});

it('should return original URL when absolute time range is present', () => {
const absoluteFrom = '2023-12-31T00:00:00Z';
const absoluteTo = '2024-01-01T00:00:00Z';
const url = `http://localhost:3000/dashboard?from=${absoluteFrom}&to=${absoluteTo}`;

const result = absoluteTimeRangeURL({ url });

expect(result).toBe(url);
});

it('should use custom parameter names when provided', () => {
const url = 'http://localhost:3000/dashboard?start=now-1h&end=now';
const result = absoluteTimeRangeURL({
url,
fromParam: 'start',
toParam: 'end',
});

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 60 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(`http://localhost:3000/dashboard?start=${expectedFrom}&end=${expectedTo}`);
});

it('should use window.location when no URL is provided', () => {
// Mock window.location
const originalLocation = window.location;
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = new URL('http://localhost:3000/dashboard?from=now-1h&to=now');

const result = absoluteTimeRangeURL();

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 60 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(`http://localhost:3000/dashboard?from=${expectedFrom}&to=${expectedTo}`);

// Restore window.location
// @ts-ignore
window.location = originalLocation;
});

it('should preserve other query parameters', () => {
const url = 'http://localhost:3000/dashboard?from=now-6h&to=now&param1=value1&param2=value2';
const result = absoluteTimeRangeURL({ url });

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 6 * 60 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(
`http://localhost:3000/dashboard?from=${expectedFrom}&to=${expectedTo}&param1=value1&param2=value2`
);
});

it('should handle URLs with hash fragments', () => {
const url = 'http://localhost:3000/dashboard?from=now-6h&to=now#panel-1';
const result = absoluteTimeRangeURL({ url });

const expectedTo = toUtc(fakeSystemTime).valueOf().toString();
const expectedFrom = toUtc(fakeSystemTime - 6 * 60 * 60 * 1000)
.valueOf()
.toString();

expect(result).toBe(`http://localhost:3000/dashboard?from=${expectedFrom}&to=${expectedTo}#panel-1`);
});

describe('error handling', () => {
it('should handle invalid URLs gracefully', () => {
const invalidUrl = 'not-a-valid-url';
const result = absoluteTimeRangeURL({ url: invalidUrl });

expect(result).toBe(invalidUrl);
// Update the test to match the actual error message
expect(console.error).toHaveBeenCalledWith(
'Error in absoluteTimeRangeURL:',
expect.objectContaining({
message: expect.stringContaining('Invalid URL'),
})
);
});

it('should handle invalid relative time ranges', () => {
const url = 'http://localhost:3000/dashboard?from=invalid&to=now';
const result = absoluteTimeRangeURL({ url });

expect(result).toBe('http://localhost:3000/dashboard?from=invalid&to=now');
expect(console.error).toHaveBeenCalledWith('Failed to convert relative time range:', expect.any(Error));
});

it('should handle null range values from convertRawToRange', () => {
// mock rangeUtil to return null values
// @ts-ignore
jest.spyOn(rangeUtil, 'convertRawToRange').mockReturnValue({ from: null, to: null, raw: { from: '', to: '' } });

const url = 'http://localhost:3000/dashboard?from=now-6h&to=now';
const result = absoluteTimeRangeURL({ url });

expect(result).toBe('http://localhost:3000/dashboard?from=now-6h&to=now');
expect(console.error).toHaveBeenCalledWith('Failed to convert relative time range:', expect.any(Error));
});
});

beforeEach(() => {
// Mock console.error to prevent actual logging during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.clearAllMocks();
});
});
Loading