diff --git a/.betterer.results b/.betterer.results index b903dc1d5e6..7eabbff1a7e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index f870c91b57a..88b28df0619 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -25,6 +25,7 @@ export class Scene extends SceneObjectBase { public activate() { super.activate(); this.urlSyncManager = new UrlSyncManager(this); + this.urlSyncManager.initSync(); } public deactivate() { diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx index d850f0e769a..2f294420b0e 100644 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -27,7 +27,7 @@ function SceneTimePickerRenderer({ model }: SceneComponentProps return ( extends SceneObjectBase< public onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { const sceneTimeRange = sceneGraph.getTimeRange(this); - sceneTimeRange.setState({ + sceneTimeRange.onTimeRangeChange({ raw: { from: toUtc(timeRange.from), to: toUtc(timeRange.to), diff --git a/public/app/features/scenes/components/index.ts b/public/app/features/scenes/components/index.ts index 89aafc6064f..c99ebfbf789 100644 --- a/public/app/features/scenes/components/index.ts +++ b/public/app/features/scenes/components/index.ts @@ -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'; diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx index 677f1a8a6d0..cc680e05d32 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx @@ -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( diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx index 6e70b2afd31..441070a79b3 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.tsx @@ -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 ); } -interface SceneGridRowState extends SceneLayoutChildState { - title: string; - isCollapsible?: boolean; - isCollapsed?: boolean; - children: Array>; -} - -export class SceneGridRow extends SceneObjectBase { - 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) { - const styles = useStyles2(getSceneGridRowStyles); - const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); - const layout = sceneGraph.getLayout(model); - const dragHandle = ; - - return ( -
-
-
- {isCollapsible && } - {title} -
- {isDraggable && isCollapsed &&
{dragHandle}
} -
-
- ); -} - -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( diff --git a/public/app/features/scenes/components/layout/SceneGridRow.tsx b/public/app/features/scenes/components/layout/SceneGridRow.tsx new file mode 100644 index 00000000000..a3572a35e0f --- /dev/null +++ b/public/app/features/scenes/components/layout/SceneGridRow.tsx @@ -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>; +} + +export class SceneGridRow extends SceneObjectBase { + 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) { + const styles = useStyles2(getSceneGridRowStyles); + const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); + const layout = sceneGraph.getLayout(model); + const dragHandle = ; + + return ( +
+
+
+ {isCollapsible && } + {title} +
+ {isDraggable && isCollapsed &&
{dragHandle}
} +
+
+ ); +} + +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, + }), + }; +}; diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index ffb997e5a1d..cf250e26be1 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -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 @@ -26,6 +26,7 @@ export abstract class SceneObjectBase | undefined; public constructor(state: TState) { if (!state.key) { @@ -57,6 +58,11 @@ export abstract class SceneObjectBase | undefined { + return this._urlSync; + } + /** * Used in render functions when rendering a SceneObject. * Wraps the component in an EditWrapper that handles edit mode diff --git a/public/app/features/scenes/core/SceneTimeRange.test.tsx b/public/app/features/scenes/core/SceneTimeRange.test.tsx new file mode 100644 index 00000000000..76baeb651d1 --- /dev/null +++ b/public/app/features/scenes/core/SceneTimeRange.test.tsx @@ -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); + }); +}); diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx index c790d5f739c..272ac46c6af 100644 --- a/public/app/features/scenes/core/SceneTimeRange.tsx +++ b/public/app/features/scenes/core/SceneTimeRange.tsx @@ -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 implements SceneTimeRangeLike { + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] }); -export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync { public constructor(state: Partial = {}) { - 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 = {}; + + 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 = {}; + + 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, + }, + }; +} diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts index 650bac14759..6d9347b8404 100644 --- a/public/app/features/scenes/core/events.ts +++ b/public/app/features/scenes/core/events.ts @@ -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; - changedObject: SceneObject | SceneObjectWithUrlSync; + changedObject: SceneObject; } export class SceneObjectStateChangedEvent extends BusEventWithPayload { diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts index 2522e2e5264..eb7279a1c68 100644 --- a/public/app/features/scenes/core/sceneGraph.ts +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -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 { /** * 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; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index 3363da75d0d..4520e0f2def 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -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; $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 /** 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; + /** Subscribe to state changes */ subscribeToState(observer?: Partial>): 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 { +export interface SceneTimeRangeLike extends SceneObject { 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 extends SceneObject { + getUrlState(state: TState): SceneObjectUrlValues; + updateFromUrl(values: SceneObjectUrlValues): void; } + +export interface SceneObjectUrlSyncHandler { + getKeys(): Set; + getUrlState(state: TState): SceneObjectUrlValues; + updateFromUrl(values: SceneObjectUrlValues): void; +} + +export type SceneObjectUrlValue = string | string[] | undefined | null; +export type SceneObjectUrlValues = Record; diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 46106a06a85..b6d23c8ee3d 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -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 { 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) { diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 94197ca42d3..4566816e31d 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -55,6 +55,10 @@ export class DashboardLoader extends StateManagerBase { 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 }); } diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index bd93f611f42..3c11b50cac8 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -52,7 +52,7 @@ export class SceneQueryRunner extends SceneObjectBase { this._subs.add( timeRange.subscribeToState({ next: (timeRange) => { - this.runWithTimeRange(timeRange); + this.runWithTimeRange(timeRange.value); }, }) ); @@ -88,7 +88,7 @@ export class SceneQueryRunner extends SceneObjectBase { 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 { public runQueries() { const timeRange = sceneGraph.getTimeRange(this); - this.runWithTimeRange(timeRange.state); + this.runWithTimeRange(timeRange.state.value); } private getMaxDataPoints() { diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index 8ed865aa812..169339a3156 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -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({ diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index 43960d9b6da..5d2e1a893ab 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -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'; diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx index 85826d52b5b..d333ee167fb 100644 --- a/public/app/features/scenes/scenes/gridWithRow.tsx +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -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'; diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx index 1aef68fb5d4..a7fa3aa7282 100644 --- a/public/app/features/scenes/scenes/gridWithRows.tsx +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -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'; diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index 728346f4138..05fb096b339 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -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(), diff --git a/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts b/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts new file mode 100644 index 00000000000..81c926b086b --- /dev/null +++ b/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts @@ -0,0 +1,30 @@ +import { + SceneObjectState, + SceneObjectUrlSyncHandler, + SceneObjectWithUrlSync, + SceneObjectUrlValues, +} from '../core/types'; + +interface SceneObjectUrlSyncConfigOptions { + keys?: string[]; +} + +export class SceneObjectUrlSyncConfig implements SceneObjectUrlSyncHandler { + private _keys: Set; + + public constructor(private _sceneObject: SceneObjectWithUrlSync, _options: SceneObjectUrlSyncConfigOptions) { + this._keys = new Set(_options.keys); + } + + public getKeys(): Set { + return this._keys; + } + + public getUrlState(state: TState): SceneObjectUrlValues { + return this._sceneObject.getUrlState(state); + } + + public updateFromUrl(values: SceneObjectUrlValues): void { + this._sceneObject.updateFromUrl(values); + } +} diff --git a/public/app/features/scenes/services/UrlSyncManager.test.ts b/public/app/features/scenes/services/UrlSyncManager.test.ts new file mode 100644 index 00000000000..165346ca1fb --- /dev/null +++ b/public/app/features/scenes/services/UrlSyncManager.test.ts @@ -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 { + 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); + }); +}); diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts index 23c5ad7e184..65a361da0a7 100644 --- a/public/app/features/scenes/services/UrlSyncManager.ts +++ b/public/app/features/scenes/services/UrlSyncManager.ts @@ -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 = 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(); + + 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); } diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index ac9da47b115..20052ded8bc 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -6,7 +6,7 @@ import { initReactI18next } from 'react-i18next'; import { matchers } from './matchers'; failOnConsole({ - shouldFailOnLog: true, + //shouldFailOnLog: true, }); expect.extend(matchers);