mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
c8c1499cd0
commit
1395436dce
@ -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(
|
||||
|
122
public/app/features/scenes/components/layout/SceneGridRow.tsx
Normal file
122
public/app/features/scenes/components/layout/SceneGridRow.tsx
Normal file
@ -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
|
||||
|
37
public/app/features/scenes/core/SceneTimeRange.test.tsx
Normal file
37
public/app/features/scenes/core/SceneTimeRange.test.tsx
Normal file
@ -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);
|
||||
}
|
||||
}
|
209
public/app/features/scenes/services/UrlSyncManager.test.ts
Normal file
209
public/app/features/scenes/services/UrlSyncManager.test.ts
Normal file
@ -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…
Reference in New Issue
Block a user