mirror of https://github.com/grafana/grafana
Scenes: Url sync (#59154)
* Scene url sync * muu * Progress * Time range stuff * Progress * Progress * Adding tests * Rennamed interface * broken test * handling of unique url keys * Fixing isuse with unique key mapping and depth * Testing grid row expand sync * Updates * Switched from Map to Object * Now arrays work * Update public/app/features/scenes/core/types.ts Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> * Update public/app/features/scenes/core/SceneTimeRange.tsx Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> * Update public/app/features/scenes/core/SceneObjectBase.tsx Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>pull/59498/head
parent
c8c1499cd0
commit
1395436dce
@ -0,0 +1,122 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Icon, useStyles2 } from '@grafana/ui'; |
||||
import { GRID_COLUMN_COUNT } from 'app/core/constants'; |
||||
|
||||
import { SceneObjectBase } from '../../core/SceneObjectBase'; |
||||
import { sceneGraph } from '../../core/sceneGraph'; |
||||
import { SceneComponentProps, SceneLayoutChildState, SceneObject, SceneObjectUrlValues } from '../../core/types'; |
||||
import { SceneObjectUrlSyncConfig } from '../../services/SceneObjectUrlSyncConfig'; |
||||
import { SceneDragHandle } from '../SceneDragHandle'; |
||||
|
||||
import { SceneGridLayout } from './SceneGridLayout'; |
||||
|
||||
export interface SceneGridRowState extends SceneLayoutChildState { |
||||
title: string; |
||||
isCollapsible?: boolean; |
||||
isCollapsed?: boolean; |
||||
children: Array<SceneObject<SceneLayoutChildState>>; |
||||
} |
||||
|
||||
export class SceneGridRow extends SceneObjectBase<SceneGridRowState> { |
||||
public static Component = SceneGridRowRenderer; |
||||
|
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['rowc'] }); |
||||
|
||||
public constructor(state: SceneGridRowState) { |
||||
super({ |
||||
isResizable: false, |
||||
isDraggable: true, |
||||
isCollapsible: true, |
||||
...state, |
||||
size: { |
||||
...state.size, |
||||
x: 0, |
||||
height: 1, |
||||
width: GRID_COLUMN_COUNT, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
public onCollapseToggle = () => { |
||||
if (!this.state.isCollapsible) { |
||||
return; |
||||
} |
||||
|
||||
const layout = this.parent; |
||||
|
||||
if (!layout || !(layout instanceof SceneGridLayout)) { |
||||
throw new Error('SceneGridRow must be a child of SceneGridLayout'); |
||||
} |
||||
|
||||
layout.toggleRow(this); |
||||
}; |
||||
|
||||
public getUrlState(state: SceneGridRowState) { |
||||
return { rowc: state.isCollapsed ? '1' : '0' }; |
||||
} |
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues) { |
||||
const isCollapsed = values.rowc === '1'; |
||||
if (isCollapsed !== this.state.isCollapsed) { |
||||
this.onCollapseToggle(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function SceneGridRowRenderer({ model }: SceneComponentProps<SceneGridRow>) { |
||||
const styles = useStyles2(getSceneGridRowStyles); |
||||
const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); |
||||
const layout = sceneGraph.getLayout(model); |
||||
const dragHandle = <SceneDragHandle layoutKey={layout.state.key!} />; |
||||
|
||||
return ( |
||||
<div className={styles.row}> |
||||
<div className={cx(styles.rowHeader, isCollapsed && styles.rowHeaderCollapsed)}> |
||||
<div onClick={model.onCollapseToggle} className={styles.rowTitleWrapper}> |
||||
{isCollapsible && <Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />} |
||||
<span className={styles.rowTitle}>{title}</span> |
||||
</div> |
||||
{isDraggable && isCollapsed && <div>{dragHandle}</div>} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getSceneGridRowStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
row: css({ |
||||
width: '100%', |
||||
height: '100%', |
||||
position: 'relative', |
||||
zIndex: 0, |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
}), |
||||
rowHeader: css({ |
||||
width: '100%', |
||||
height: '30px', |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
marginBottom: '8px', |
||||
border: `1px solid transparent`, |
||||
}), |
||||
rowTitleWrapper: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
cursor: 'pointer', |
||||
}), |
||||
rowHeaderCollapsed: css({ |
||||
marginBottom: '0px', |
||||
background: theme.colors.background.primary, |
||||
border: `1px solid ${theme.colors.border.weak}`, |
||||
borderRadius: theme.shape.borderRadius(1), |
||||
}), |
||||
rowTitle: css({ |
||||
fontSize: theme.typography.h6.fontSize, |
||||
fontWeight: theme.typography.h6.fontWeight, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,37 @@ |
||||
import { SceneTimeRange } from './SceneTimeRange'; |
||||
|
||||
describe('SceneTimeRange', () => { |
||||
it('when created should evaluate time range', () => { |
||||
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); |
||||
expect(timeRange.state.value.raw.from).toBe('now-1h'); |
||||
}); |
||||
|
||||
it('when time range refreshed should evaluate and update value', async () => { |
||||
const timeRange = new SceneTimeRange({ from: 'now-30s', to: 'now' }); |
||||
const startTime = timeRange.state.value.from.valueOf(); |
||||
await new Promise((r) => setTimeout(r, 2)); |
||||
timeRange.onRefresh(); |
||||
const diff = timeRange.state.value.from.valueOf() - startTime; |
||||
expect(diff).toBeGreaterThan(1); |
||||
expect(diff).toBeLessThan(100); |
||||
}); |
||||
|
||||
it('toUrlValues with relative range', () => { |
||||
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); |
||||
expect(timeRange.urlSync?.getUrlState(timeRange.state)).toEqual({ |
||||
from: 'now-1h', |
||||
to: 'now', |
||||
}); |
||||
}); |
||||
|
||||
it('updateFromUrl with ISO time', () => { |
||||
const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); |
||||
timeRange.urlSync?.updateFromUrl({ |
||||
from: '2021-01-01T10:00:00.000Z', |
||||
to: '2021-02-03T01:20:00.000Z', |
||||
}); |
||||
|
||||
expect(timeRange.state.from).toEqual('2021-01-01T10:00:00.000Z'); |
||||
expect(timeRange.state.value.from.valueOf()).toEqual(1609495200000); |
||||
}); |
||||
}); |
@ -1,37 +1,107 @@ |
||||
import { getDefaultTimeRange, getTimeZone, TimeRange, UrlQueryMap } from '@grafana/data'; |
||||
import { dateMath, getTimeZone, TimeRange, TimeZone, toUtc } from '@grafana/data'; |
||||
|
||||
import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig'; |
||||
|
||||
import { SceneObjectBase } from './SceneObjectBase'; |
||||
import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types'; |
||||
import { SceneTimeRangeLike, SceneTimeRangeState, SceneObjectUrlValues, SceneObjectUrlValue } from './types'; |
||||
|
||||
export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> implements SceneTimeRangeLike { |
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] }); |
||||
|
||||
export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> implements SceneObjectWithUrlSync { |
||||
public constructor(state: Partial<SceneTimeRangeState> = {}) { |
||||
super({ |
||||
...getDefaultTimeRange(), |
||||
timeZone: getTimeZone(), |
||||
...state, |
||||
}); |
||||
const from = state.from ?? 'now-6h'; |
||||
const to = state.to ?? 'now'; |
||||
const timeZone = state.timeZone ?? getTimeZone(); |
||||
const value = evaluateTimeRange(from, to, timeZone); |
||||
super({ from, to, timeZone, value, ...state }); |
||||
} |
||||
|
||||
public onTimeRangeChange = (timeRange: TimeRange) => { |
||||
this.setState(timeRange); |
||||
const update: Partial<SceneTimeRangeState> = {}; |
||||
|
||||
if (typeof timeRange.raw.from === 'string') { |
||||
update.from = timeRange.raw.from; |
||||
} else { |
||||
update.from = timeRange.raw.from.toISOString(); |
||||
} |
||||
|
||||
if (typeof timeRange.raw.to === 'string') { |
||||
update.to = timeRange.raw.to; |
||||
} else { |
||||
update.to = timeRange.raw.to.toISOString(); |
||||
} |
||||
|
||||
update.value = evaluateTimeRange(update.from, update.to, this.state.timeZone); |
||||
this.setState(update); |
||||
}; |
||||
|
||||
public onRefresh = () => { |
||||
// TODO re-eval time range
|
||||
this.setState({ ...this.state }); |
||||
this.setState({ value: evaluateTimeRange(this.state.from, this.state.to, this.state.timeZone) }); |
||||
}; |
||||
|
||||
public onIntervalChanged = (_: string) => {}; |
||||
|
||||
/** These url sync functions are only placeholders for something more sophisticated */ |
||||
public getUrlState() { |
||||
return { |
||||
from: this.state.raw.from, |
||||
to: this.state.raw.to, |
||||
} as any; |
||||
public getUrlState(state: SceneTimeRangeState) { |
||||
return { from: state.from, to: state.to }; |
||||
} |
||||
|
||||
public updateFromUrl(values: UrlQueryMap) { |
||||
// TODO
|
||||
public updateFromUrl(values: SceneObjectUrlValues) { |
||||
const update: Partial<SceneTimeRangeState> = {}; |
||||
|
||||
const from = parseUrlParam(values.from); |
||||
if (from) { |
||||
update.from = from; |
||||
} |
||||
|
||||
const to = parseUrlParam(values.to); |
||||
if (to) { |
||||
update.to = to; |
||||
} |
||||
|
||||
update.value = evaluateTimeRange(update.from ?? this.state.from, update.to ?? this.state.to, this.state.timeZone); |
||||
this.setState(update); |
||||
} |
||||
} |
||||
|
||||
function parseUrlParam(value: SceneObjectUrlValue): string | null { |
||||
if (typeof value !== 'string') { |
||||
return null; |
||||
} |
||||
|
||||
if (value.indexOf('now') !== -1) { |
||||
return value; |
||||
} |
||||
|
||||
if (value.length === 8) { |
||||
const utcValue = toUtc(value, 'YYYYMMDD'); |
||||
if (utcValue.isValid()) { |
||||
return utcValue.toISOString(); |
||||
} |
||||
} else if (value.length === 15) { |
||||
const utcValue = toUtc(value, 'YYYYMMDDTHHmmss'); |
||||
if (utcValue.isValid()) { |
||||
return utcValue.toISOString(); |
||||
} |
||||
} else if (value.length === 24) { |
||||
const utcValue = toUtc(value); |
||||
return utcValue.toISOString(); |
||||
} |
||||
|
||||
const epoch = parseInt(value, 10); |
||||
if (!isNaN(epoch)) { |
||||
return toUtc(epoch).toISOString(); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
function evaluateTimeRange(from: string, to: string, timeZone: TimeZone, fiscalYearStartMonth?: number): TimeRange { |
||||
return { |
||||
from: dateMath.parse(from, false, timeZone, fiscalYearStartMonth)!, |
||||
to: dateMath.parse(to, true, timeZone, fiscalYearStartMonth)!, |
||||
raw: { |
||||
from: from, |
||||
to: to, |
||||
}, |
||||
}; |
||||
} |
||||
|
@ -0,0 +1,30 @@ |
||||
import { |
||||
SceneObjectState, |
||||
SceneObjectUrlSyncHandler, |
||||
SceneObjectWithUrlSync, |
||||
SceneObjectUrlValues, |
||||
} from '../core/types'; |
||||
|
||||
interface SceneObjectUrlSyncConfigOptions { |
||||
keys?: string[]; |
||||
} |
||||
|
||||
export class SceneObjectUrlSyncConfig<TState extends SceneObjectState> implements SceneObjectUrlSyncHandler<TState> { |
||||
private _keys: Set<string>; |
||||
|
||||
public constructor(private _sceneObject: SceneObjectWithUrlSync<TState>, _options: SceneObjectUrlSyncConfigOptions) { |
||||
this._keys = new Set(_options.keys); |
||||
} |
||||
|
||||
public getKeys(): Set<string> { |
||||
return this._keys; |
||||
} |
||||
|
||||
public getUrlState(state: TState): SceneObjectUrlValues { |
||||
return this._sceneObject.getUrlState(state); |
||||
} |
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues): void { |
||||
this._sceneObject.updateFromUrl(values); |
||||
} |
||||
} |
@ -0,0 +1,209 @@ |
||||
import { Location } from 'history'; |
||||
|
||||
import { locationService } from '@grafana/runtime'; |
||||
|
||||
import { SceneFlexLayout } from '../components'; |
||||
import { SceneObjectBase } from '../core/SceneObjectBase'; |
||||
import { SceneTimeRange } from '../core/SceneTimeRange'; |
||||
import { SceneLayoutChildState, SceneObjectUrlValues } from '../core/types'; |
||||
|
||||
import { SceneObjectUrlSyncConfig } from './SceneObjectUrlSyncConfig'; |
||||
import { isUrlValueEqual, UrlSyncManager } from './UrlSyncManager'; |
||||
|
||||
interface TestObjectState extends SceneLayoutChildState { |
||||
name: string; |
||||
array?: string[]; |
||||
other?: string; |
||||
} |
||||
|
||||
class TestObj extends SceneObjectBase<TestObjectState> { |
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { |
||||
keys: ['name', 'array'], |
||||
}); |
||||
|
||||
public getUrlState(state: TestObjectState) { |
||||
return { name: state.name, array: state.array }; |
||||
} |
||||
|
||||
public updateFromUrl(values: SceneObjectUrlValues) { |
||||
if (typeof values.name === 'string') { |
||||
this.setState({ name: values.name ?? 'NA' }); |
||||
} |
||||
if (Array.isArray(values.array)) { |
||||
this.setState({ array: values.array }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
describe('UrlSyncManager', () => { |
||||
let urlManager: UrlSyncManager; |
||||
let locationUpdates: Location[] = []; |
||||
let listenUnregister: () => void; |
||||
|
||||
beforeEach(() => { |
||||
locationUpdates = []; |
||||
listenUnregister = locationService.getHistory().listen((location) => { |
||||
locationUpdates.push(location); |
||||
}); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
urlManager.cleanUp(); |
||||
locationService.push('/'); |
||||
listenUnregister(); |
||||
}); |
||||
|
||||
describe('When state changes', () => { |
||||
it('should update url', () => { |
||||
const obj = new TestObj({ name: 'test' }); |
||||
const scene = new SceneFlexLayout({ |
||||
children: [obj], |
||||
}); |
||||
|
||||
urlManager = new UrlSyncManager(scene); |
||||
|
||||
// When making state change
|
||||
obj.setState({ name: 'test2' }); |
||||
|
||||
// Should update url
|
||||
const searchObj = locationService.getSearchObject(); |
||||
expect(searchObj.name).toBe('test2'); |
||||
|
||||
// When making unrelated state change
|
||||
obj.setState({ other: 'not synced' }); |
||||
|
||||
// Should not update url
|
||||
expect(locationUpdates.length).toBe(1); |
||||
|
||||
// When clearing url (via go back)
|
||||
locationService.getHistory().goBack(); |
||||
|
||||
// Should restore to initial state
|
||||
expect(obj.state.name).toBe('test'); |
||||
}); |
||||
}); |
||||
|
||||
describe('When url changes', () => { |
||||
it('should update state', () => { |
||||
const obj = new TestObj({ name: 'test' }); |
||||
const initialObjState = obj.state; |
||||
const scene = new SceneFlexLayout({ |
||||
children: [obj], |
||||
}); |
||||
|
||||
urlManager = new UrlSyncManager(scene); |
||||
|
||||
// When non relevant key changes in url
|
||||
locationService.partial({ someOtherProp: 'test2' }); |
||||
// Should not affect state
|
||||
expect(obj.state).toBe(initialObjState); |
||||
|
||||
// When relevant key changes in url
|
||||
locationService.partial({ name: 'test2' }); |
||||
// Should update state
|
||||
expect(obj.state.name).toBe('test2'); |
||||
|
||||
// When relevant key is cleared (say go back)
|
||||
locationService.partial({ name: null }); |
||||
// Should revert to initial state
|
||||
expect(obj.state.name).toBe('test'); |
||||
|
||||
// When relevant key is set to current state
|
||||
const currentState = obj.state; |
||||
locationService.partial({ name: currentState.name }); |
||||
// Should not affect state (same instance)
|
||||
expect(obj.state).toBe(currentState); |
||||
}); |
||||
}); |
||||
|
||||
describe('When multiple scene objects wants to set same url keys', () => { |
||||
it('should give each object a unique key', () => { |
||||
const outerTimeRange = new SceneTimeRange(); |
||||
const innerTimeRange = new SceneTimeRange(); |
||||
|
||||
const scene = new SceneFlexLayout({ |
||||
children: [ |
||||
new SceneFlexLayout({ |
||||
$timeRange: innerTimeRange, |
||||
children: [], |
||||
}), |
||||
], |
||||
$timeRange: outerTimeRange, |
||||
}); |
||||
|
||||
urlManager = new UrlSyncManager(scene); |
||||
|
||||
// When making state changes for second object with same key
|
||||
innerTimeRange.setState({ from: 'now-10m' }); |
||||
|
||||
// Should use unique key based where it is in the scene
|
||||
expect(locationService.getSearchObject()).toEqual({ |
||||
['from-2']: 'now-10m', |
||||
['to-2']: 'now', |
||||
}); |
||||
|
||||
outerTimeRange.setState({ from: 'now-20m' }); |
||||
|
||||
// Should not suffix key for first object
|
||||
expect(locationService.getSearchObject()).toEqual({ |
||||
from: 'now-20m', |
||||
to: 'now', |
||||
['from-2']: 'now-10m', |
||||
['to-2']: 'now', |
||||
}); |
||||
|
||||
// When updating via url
|
||||
locationService.partial({ ['from-2']: 'now-10s' }); |
||||
// should find the correct object
|
||||
expect(innerTimeRange.state.from).toBe('now-10s'); |
||||
// should not update the first object
|
||||
expect(outerTimeRange.state.from).toBe('now-20m'); |
||||
// Should not cause another url update
|
||||
expect(locationUpdates.length).toBe(3); |
||||
}); |
||||
}); |
||||
|
||||
describe('When updating array value', () => { |
||||
it('Should update url correctly', () => { |
||||
const obj = new TestObj({ name: 'test' }); |
||||
const scene = new SceneFlexLayout({ |
||||
children: [obj], |
||||
}); |
||||
|
||||
urlManager = new UrlSyncManager(scene); |
||||
|
||||
// When making state change
|
||||
obj.setState({ array: ['A', 'B'] }); |
||||
|
||||
// Should update url
|
||||
const searchObj = locationService.getSearchObject(); |
||||
expect(searchObj.array).toEqual(['A', 'B']); |
||||
|
||||
// When making unrelated state change
|
||||
obj.setState({ other: 'not synced' }); |
||||
|
||||
// Should not update url
|
||||
expect(locationUpdates.length).toBe(1); |
||||
|
||||
// When updating via url
|
||||
locationService.partial({ array: ['A', 'B', 'C'] }); |
||||
// Should update state
|
||||
expect(obj.state.array).toEqual(['A', 'B', 'C']); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('isUrlValueEqual', () => { |
||||
it('should handle all cases', () => { |
||||
expect(isUrlValueEqual([], [])).toBe(true); |
||||
expect(isUrlValueEqual([], undefined)).toBe(true); |
||||
expect(isUrlValueEqual([], null)).toBe(true); |
||||
|
||||
expect(isUrlValueEqual(['asd'], 'asd')).toBe(true); |
||||
expect(isUrlValueEqual(['asd'], ['asd'])).toBe(true); |
||||
expect(isUrlValueEqual(['asd', '2'], ['asd', '2'])).toBe(true); |
||||
|
||||
expect(isUrlValueEqual(['asd', '2'], 'asd')).toBe(false); |
||||
expect(isUrlValueEqual(['asd2'], 'asd')).toBe(false); |
||||
}); |
||||
}); |
Loading…
Reference in new issue