-
Notifications
You must be signed in to change notification settings - Fork 3
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
eskirk
wants to merge
1
commit into
main
Choose a base branch
from
elliot/share-time-range-button
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
30 changes: 30 additions & 0 deletions
30
src/components/ShareTimeRangeButton/ShareTimeRangeButton.mdx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> |
43 changes: 43 additions & 0 deletions
43
src/components/ShareTimeRangeButton/ShareTimeRangeButton.story.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} />; | ||
}; | ||
|
||
export default meta; |
110 changes: 110 additions & 0 deletions
110
src/components/ShareTimeRangeButton/ShareTimeRangeButton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import { css } from '@emotion/css'; | ||
import { 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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> | ||
<Menu.Item | ||
key="copy-url-relative" | ||
label={t('grafana-ui.toolbar.copy-link', 'Copy URL')} | ||
icon="link" | ||
onClick={() => clickHandler(url, relativeUrlRef)} | ||
ref={relativeUrlRef} | ||
/> | ||
<Menu.Item | ||
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> | ||
<ClipboardButton | ||
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}> | ||
<Trans i18nKey="grafana-ui.toolbar.copy-shortened-link-label">Share</Trans> | ||
</span> | ||
</ClipboardButton> | ||
<Dropdown overlay={menu} placement="bottom-start" onVisibleChange={() => setIsOpen(!isOpen)}> | ||
<Button variant="secondary" size="md" icon={isOpen ? 'angle-up' : 'angle-down'} /> | ||
</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)}`, | ||
}), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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¶m1=value1¶m2=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}¶m1=value1¶m2=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(); | ||
}); | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.