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
Torkel Ödegaard 3 years ago committed by GitHub
parent c8c1499cd0
commit 1395436dce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .betterer.results
  2. 1
      public/app/features/scenes/components/Scene.tsx
  3. 2
      public/app/features/scenes/components/SceneTimePicker.tsx
  4. 2
      public/app/features/scenes/components/VizPanel/VizPanel.tsx
  5. 3
      public/app/features/scenes/components/index.ts
  6. 3
      public/app/features/scenes/components/layout/SceneGridLayout.test.tsx
  7. 111
      public/app/features/scenes/components/layout/SceneGridLayout.tsx
  8. 122
      public/app/features/scenes/components/layout/SceneGridRow.tsx
  9. 8
      public/app/features/scenes/core/SceneObjectBase.tsx
  10. 37
      public/app/features/scenes/core/SceneTimeRange.test.tsx
  11. 108
      public/app/features/scenes/core/SceneTimeRange.tsx
  12. 4
      public/app/features/scenes/core/events.ts
  13. 4
      public/app/features/scenes/core/sceneGraph.ts
  14. 32
      public/app/features/scenes/core/types.ts
  15. 22
      public/app/features/scenes/dashboard/DashboardScene.tsx
  16. 4
      public/app/features/scenes/dashboard/DashboardsLoader.ts
  17. 6
      public/app/features/scenes/querying/SceneQueryRunner.ts
  18. 13
      public/app/features/scenes/scenes/gridMultiTimeRange.tsx
  19. 4
      public/app/features/scenes/scenes/gridWithMultipleData.tsx
  20. 3
      public/app/features/scenes/scenes/gridWithRow.tsx
  21. 4
      public/app/features/scenes/scenes/gridWithRows.tsx
  22. 2
      public/app/features/scenes/scenes/nested.tsx
  23. 30
      public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts
  24. 209
      public/app/features/scenes/services/UrlSyncManager.test.ts
  25. 153
      public/app/features/scenes/services/UrlSyncManager.ts
  26. 2
      public/test/setupTests.ts

@ -4556,10 +4556,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/scenes/core/sceneGraph.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

@ -25,6 +25,7 @@ export class Scene extends SceneObjectBase<SceneState> {
public activate() {
super.activate();
this.urlSyncManager = new UrlSyncManager(this);
this.urlSyncManager.initSync();
}
public deactivate() {

@ -27,7 +27,7 @@ function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>
return (
<ToolbarButtonRow alignment="right">
<TimePickerWithHistory
value={timeRangeState}
value={timeRangeState.value}
onChange={timeRange.onTimeRangeChange}
timeZone={'browser'}
fiscalYearStartMonth={0}

@ -95,7 +95,7 @@ export class VizPanel<TOptions = {}, TFieldConfig = {}> extends SceneObjectBase<
public onChangeTimeRange = (timeRange: AbsoluteTimeRange) => {
const sceneTimeRange = sceneGraph.getTimeRange(this);
sceneTimeRange.setState({
sceneTimeRange.onTimeRangeChange({
raw: {
from: toUtc(timeRange.from),
to: toUtc(timeRange.to),

@ -7,4 +7,5 @@ export { SceneTimePicker } from './SceneTimePicker';
export { ScenePanelRepeater } from './ScenePanelRepeater';
export { SceneSubMenu } from './SceneSubMenu';
export { SceneFlexLayout } from './layout/SceneFlexLayout';
export { SceneGridLayout, SceneGridRow } from './layout/SceneGridLayout';
export { SceneGridLayout } from './layout/SceneGridLayout';
export { SceneGridRow } from './layout/SceneGridRow';

@ -7,7 +7,8 @@ import { SceneObjectBase } from '../../core/SceneObjectBase';
import { SceneComponentProps, SceneLayoutChildState } from '../../core/types';
import { Scene } from '../Scene';
import { SceneGridLayout, SceneGridRow } from './SceneGridLayout';
import { SceneGridLayout } from './SceneGridLayout';
import { SceneGridRow } from './SceneGridRow';
// Mocking AutoSizer to allow testing of the SceneGridLayout component rendering
jest.mock(

@ -1,23 +1,13 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import ReactGridLayout from 'react-grid-layout';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { SceneObjectBase } from '../../core/SceneObjectBase';
import { sceneGraph } from '../../core/sceneGraph';
import {
SceneComponentProps,
SceneLayoutChild,
SceneLayoutChildState,
SceneLayoutState,
SceneObject,
SceneObjectSize,
} from '../../core/types';
import { SceneDragHandle } from '../SceneDragHandle';
import { SceneComponentProps, SceneLayoutChild, SceneLayoutState, SceneObjectSize } from '../../core/types';
import { SceneGridRow } from './SceneGridRow';
interface SceneGridLayoutState extends SceneLayoutState {}
@ -369,101 +359,6 @@ function SceneGridLayoutRenderer({ model }: SceneComponentProps<SceneGridLayout>
);
}
interface SceneGridRowState extends SceneLayoutChildState {
title: string;
isCollapsible?: boolean;
isCollapsed?: boolean;
children: Array<SceneObject<SceneLayoutChildState>>;
}
export class SceneGridRow extends SceneObjectBase<SceneGridRowState> {
public static Component = SceneGridRowRenderer;
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);
};
}
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,
}),
};
};
function validateChildrenSize(children: SceneLayoutChild[]) {
if (
children.find(

@ -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,
}),
};
};

@ -9,7 +9,7 @@ import { SceneVariableDependencyConfigLike } from '../variables/types';
import { SceneComponentWrapper } from './SceneComponentWrapper';
import { SceneObjectStateChangedEvent } from './events';
import { SceneObject, SceneComponent, SceneObjectState } from './types';
import { SceneObject, SceneComponent, SceneObjectState, SceneObjectUrlSyncHandler } from './types';
import { cloneSceneObject, forEachSceneObjectInState } from './utils';
export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObjectState>
@ -26,6 +26,7 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
protected _subs = new Subscription();
protected _variableDependency: SceneVariableDependencyConfigLike | undefined;
protected _urlSync: SceneObjectUrlSyncHandler<TState> | undefined;
public constructor(state: TState) {
if (!state.key) {
@ -57,6 +58,11 @@ export abstract class SceneObjectBase<TState extends SceneObjectState = SceneObj
return this._variableDependency;
}
/** Returns url sync config */
public get urlSync(): SceneObjectUrlSyncHandler<TState> | undefined {
return this._urlSync;
}
/**
* Used in render functions when rendering a SceneObject.
* Wraps the component in an EditWrapper that handles edit mode

@ -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,
},
};
}

@ -1,12 +1,12 @@
import { BusEventWithPayload } from '@grafana/data';
import { SceneObject, SceneObjectState, SceneObjectWithUrlSync } from './types';
import { SceneObject, SceneObjectState } from './types';
export interface SceneObjectStateChangedPayload {
prevState: SceneObjectState;
newState: SceneObjectState;
partialUpdate: Partial<SceneObjectState>;
changedObject: SceneObject | SceneObjectWithUrlSync;
changedObject: SceneObject;
}
export class SceneObjectStateChangedEvent extends BusEventWithPayload<SceneObjectStateChangedPayload> {

@ -6,7 +6,7 @@ import { SceneVariables } from '../variables/types';
import { SceneDataNode } from './SceneDataNode';
import { SceneTimeRange as SceneTimeRangeImpl } from './SceneTimeRange';
import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRange } from './types';
import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRangeLike } from './types';
/**
* Get the closest node with variables
@ -42,7 +42,7 @@ export function getData(sceneObject: SceneObject): SceneObject<SceneDataState> {
/**
* Will walk up the scene object graph to the closest $timeRange scene object
*/
export function getTimeRange(sceneObject: SceneObject): SceneTimeRange {
export function getTimeRange(sceneObject: SceneObject): SceneTimeRangeLike {
const { $timeRange } = sceneObject.state;
if ($timeRange) {
return $timeRange;

@ -1,13 +1,13 @@
import React from 'react';
import { Observer, Subscription, Unsubscribable } from 'rxjs';
import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone, UrlQueryMap } from '@grafana/data';
import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone } from '@grafana/data';
import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types';
export interface SceneObjectStatePlain {
key?: string;
$timeRange?: SceneTimeRange;
$timeRange?: SceneTimeRangeLike;
$data?: SceneObject<SceneDataState>;
$editor?: SceneEditor;
$variables?: SceneVariables;
@ -19,8 +19,6 @@ export interface SceneLayoutChildSize {
export interface SceneLayoutChildInteractions {
isDraggable?: boolean;
isResizable?: boolean;
isCollapsible?: boolean;
isCollapsed?: boolean;
}
export interface SceneLayoutChildState
@ -65,6 +63,9 @@ export interface SceneObject<TState extends SceneObjectState = SceneObjectState>
/** This abtractions declares what variables the scene object depends on and how to handle when they change value. **/
readonly variableDependency?: SceneVariableDependencyConfigLike;
/** This abstraction declares URL sync dependencies of a scene object. **/
readonly urlSync?: SceneObjectUrlSyncHandler<TState>;
/** Subscribe to state changes */
subscribeToState(observer?: Partial<Observer<TState>>): Subscription;
@ -128,11 +129,15 @@ interface SceneComponentEditWrapperProps {
children: React.ReactNode;
}
export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {
export interface SceneTimeRangeState extends SceneObjectStatePlain {
from: string;
to: string;
timeZone: TimeZone;
fiscalYearStartMonth?: number;
value: TimeRange;
}
export interface SceneTimeRange extends SceneObject<SceneTimeRangeState> {
export interface SceneTimeRangeLike extends SceneObject<SceneTimeRangeState> {
onTimeRangeChange(timeRange: TimeRange): void;
onIntervalChanged(interval: string): void;
onRefresh(): void;
@ -147,7 +152,16 @@ export function isSceneObject(obj: any): obj is SceneObject {
}
/** These functions are still just temporary until this get's refined */
export interface SceneObjectWithUrlSync extends SceneObject {
getUrlState(): UrlQueryMap;
updateFromUrl(values: UrlQueryMap): void;
export interface SceneObjectWithUrlSync<TState> extends SceneObject {
getUrlState(state: TState): SceneObjectUrlValues;
updateFromUrl(values: SceneObjectUrlValues): void;
}
export interface SceneObjectUrlSyncHandler<TState> {
getKeys(): Set<string>;
getUrlState(state: TState): SceneObjectUrlValues;
updateFromUrl(values: SceneObjectUrlValues): void;
}
export type SceneObjectUrlValue = string | string[] | undefined | null;
export type SceneObjectUrlValues = Record<string, SceneObjectUrlValue>;

@ -8,6 +8,7 @@ import { Page } from 'app/core/components/Page/Page';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneLayout, SceneObject, SceneObjectStatePlain } from '../core/types';
import { UrlSyncManager } from '../services/UrlSyncManager';
interface DashboardSceneState extends SceneObjectStatePlain {
title: string;
@ -18,6 +19,27 @@ interface DashboardSceneState extends SceneObjectStatePlain {
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
public static Component = DashboardSceneRenderer;
private urlSyncManager?: UrlSyncManager;
public activate() {
super.activate();
}
/**
* It's better to do this before activate / mount to not trigger unnessary re-renders
*/
public initUrlSync() {
this.urlSyncManager = new UrlSyncManager(this);
this.urlSyncManager.initSync();
}
public deactivate() {
super.deactivate();
if (this.urlSyncManager) {
this.urlSyncManager!.cleanUp();
}
}
}
function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {

@ -55,6 +55,10 @@ export class DashboardLoader extends StateManagerBase<DashboardLoaderState> {
actions: [new SceneTimePicker({})],
});
// We initialize URL sync here as it better to do that before mounting and doing any rendering.
// But would be nice to have a conditional around this so you can pre-load dashboards without url sync.
dashboard.initUrlSync();
this.cache[rsp.dashboard.uid] = dashboard;
this.setState({ dashboard, isLoading: false });
}

@ -52,7 +52,7 @@ export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
this._subs.add(
timeRange.subscribeToState({
next: (timeRange) => {
this.runWithTimeRange(timeRange);
this.runWithTimeRange(timeRange.value);
},
})
);
@ -88,7 +88,7 @@ export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
public setContainerWidth(width: number) {
// If we don't have a width we should run queries
if (!this._containerWidth) {
if (!this._containerWidth && width > 0) {
this._containerWidth = width;
// If we don't have maxDataPoints specifically set and maxDataPointsFromWidth is true
@ -108,7 +108,7 @@ export class SceneQueryRunner extends SceneObjectBase<QueryRunnerState> {
public runQueries() {
const timeRange = sceneGraph.getTimeRange(this);
this.runWithTimeRange(timeRange.state);
this.runWithTimeRange(timeRange.state.value);
}
private getMaxDataPoints() {

@ -1,9 +1,7 @@
import { dateTime } from '@grafana/data';
import { VizPanel } from '../components';
import { VizPanel, SceneGridRow } from '../components';
import { Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';
@ -11,12 +9,9 @@ import { getQueryRunnerWithRandomWalkQuery } from './queries';
export function getGridWithMultipleTimeRanges(): Scene {
const globalTimeRange = new SceneTimeRange();
const now = dateTime();
const row1TimeRange = new SceneTimeRange({
from: dateTime(now).subtract(1, 'year'),
to: now,
raw: { from: 'now-1y', to: 'now' },
from: 'now-1y',
to: 'now',
});
const scene = new Scene({

@ -1,7 +1,7 @@
import { VizPanel } from '../components';
import { VizPanel, SceneGridRow } from '../components';
import { Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';

@ -1,7 +1,6 @@
import { VizPanel } from '../components';
import { VizPanel, SceneGridLayout, SceneGridRow } from '../components';
import { Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';

@ -1,8 +1,8 @@
import { VizPanel } from '../components';
import { VizPanel, SceneGridRow } from '../components';
import { Scene } from '../components/Scene';
import { SceneTimePicker } from '../components/SceneTimePicker';
import { SceneFlexLayout } from '../components/layout/SceneFlexLayout';
import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout';
import { SceneGridLayout } from '../components/layout/SceneGridLayout';
import { SceneTimeRange } from '../core/SceneTimeRange';
import { SceneEditManager } from '../editor/SceneEditManager';

@ -13,12 +13,12 @@ export function getNestedScene(): Scene {
layout: new SceneFlexLayout({
direction: 'column',
children: [
getInnerScene('Inner scene'),
new VizPanel({
key: '3',
pluginId: 'timeseries',
title: 'Panel 3',
}),
getInnerScene('Inner scene'),
],
}),
$timeRange: new SceneTimeRange(),

@ -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);
});
});

@ -1,30 +1,70 @@
import { Location } from 'history';
import { isEqual } from 'lodash';
import { Unsubscribable } from 'rxjs';
import { locationService } from '@grafana/runtime';
import { SceneObjectStateChangedEvent } from '../core/events';
import { SceneObject } from '../core/types';
import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types';
import { forEachSceneObjectInState } from '../core/utils';
export class UrlSyncManager {
private locationListenerUnsub: () => void;
private stateChangeSub: Unsubscribable;
private initialStates: Map<string, SceneObjectUrlValue> = new Map();
private urlKeyMapper = new UniqueUrlKeyMapper();
public constructor(sceneRoot: SceneObject) {
public constructor(private sceneRoot: SceneObject) {
this.stateChangeSub = sceneRoot.subscribeToEvent(SceneObjectStateChangedEvent, this.onStateChanged);
this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate);
}
/**
* Updates the current scene state to match URL state.
*/
public initSync() {
const urlParams = locationService.getSearch();
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
this.syncSceneStateFromUrl(this.sceneRoot, urlParams);
}
private onLocationUpdate = (location: Location) => {
// TODO: find any scene object whose state we need to update
const urlParams = new URLSearchParams(location.search);
// Rebuild key mapper index before starting sync
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
// Sync scene state tree from url
this.syncSceneStateFromUrl(this.sceneRoot, urlParams);
};
private onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => {
const changedObject = payload.changedObject;
if ('getUrlState' in changedObject) {
const urlUpdate = changedObject.getUrlState();
locationService.partial(urlUpdate, true);
if (changedObject.urlSync) {
const newUrlState = changedObject.urlSync.getUrlState(payload.newState);
const prevUrlState = changedObject.urlSync.getUrlState(payload.prevState);
const searchParams = locationService.getSearch();
const mappedUpdated: SceneObjectUrlValues = {};
this.urlKeyMapper.rebuldIndex(this.sceneRoot);
for (const [key, newUrlValue] of Object.entries(newUrlState)) {
const uniqueKey = this.urlKeyMapper.getUniqueKey(key, changedObject);
const currentUrlValue = searchParams.getAll(uniqueKey);
if (!isUrlValueEqual(currentUrlValue, newUrlValue)) {
mappedUpdated[uniqueKey] = newUrlValue;
// Remember the initial state so we can go back to it
if (!this.initialStates.has(uniqueKey) && prevUrlState[key] !== undefined) {
this.initialStates.set(uniqueKey, prevUrlState[key]);
}
}
}
if (Object.keys(mappedUpdated).length > 0) {
locationService.partial(mappedUpdated, false);
}
}
};
@ -32,4 +72,105 @@ export class UrlSyncManager {
this.stateChangeSub.unsubscribe();
this.locationListenerUnsub();
}
private syncSceneStateFromUrl(sceneObject: SceneObject, urlParams: URLSearchParams) {
if (sceneObject.urlSync) {
const urlState: SceneObjectUrlValues = {};
const currentState = sceneObject.urlSync.getUrlState(sceneObject.state);
for (const key of sceneObject.urlSync.getKeys()) {
const uniqueKey = this.urlKeyMapper.getUniqueKey(key, sceneObject);
const newValue = urlParams.getAll(uniqueKey);
const currentValue = currentState[key];
if (isUrlValueEqual(newValue, currentValue)) {
continue;
}
if (newValue.length > 0) {
if (Array.isArray(currentValue)) {
urlState[key] = newValue;
} else {
urlState[key] = newValue[0];
}
// Remember the initial state so we can go back to it
if (!this.initialStates.has(uniqueKey) && currentValue !== undefined) {
this.initialStates.set(uniqueKey, currentValue);
}
} else {
const initialValue = this.initialStates.get(uniqueKey);
if (initialValue !== undefined) {
urlState[key] = initialValue;
}
}
}
if (Object.keys(urlState).length > 0) {
sceneObject.urlSync.updateFromUrl(urlState);
}
}
forEachSceneObjectInState(sceneObject.state, (obj) => this.syncSceneStateFromUrl(obj, urlParams));
}
}
interface SceneObjectWithDepth {
sceneObject: SceneObject;
depth: number;
}
class UniqueUrlKeyMapper {
private index = new Map<string, SceneObjectWithDepth[]>();
public getUniqueKey(key: string, obj: SceneObject) {
const objectsWithKey = this.index.get(key);
if (!objectsWithKey) {
throw new Error("Cannot find any scene object that uses the key '" + key + "'");
}
const address = objectsWithKey.findIndex((o) => o.sceneObject === obj);
if (address > 0) {
return `${key}-${address + 1}`;
}
return key;
}
public rebuldIndex(root: SceneObject) {
this.index.clear();
this.buildIndex(root, 0);
}
private buildIndex(sceneObject: SceneObject, depth: number) {
if (sceneObject.urlSync) {
for (const key of sceneObject.urlSync.getKeys()) {
const hit = this.index.get(key);
if (hit) {
hit.push({ sceneObject, depth });
hit.sort((a, b) => a.depth - b.depth);
} else {
this.index.set(key, [{ sceneObject, depth }]);
}
}
}
forEachSceneObjectInState(sceneObject.state, (obj) => this.buildIndex(obj, depth + 1));
}
}
export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean {
if (currentUrlValue.length === 0 && newUrlValue == null) {
return true;
}
if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) {
return newUrlValue === currentUrlValue[0];
}
if (newUrlValue?.length === 0 && currentUrlValue === null) {
return true;
}
// We have two arrays, lets compare them
return isEqual(currentUrlValue, newUrlValue);
}

@ -6,7 +6,7 @@ import { initReactI18next } from 'react-i18next';
import { matchers } from './matchers';
failOnConsole({
shouldFailOnLog: true,
//shouldFailOnLog: true,
});
expect.extend(matchers);

Loading…
Cancel
Save