diff --git a/web/ui/react-app/src/pages/PanelList.tsx b/web/ui/react-app/src/pages/PanelList.tsx index 347866e85e..c04e247bd4 100644 --- a/web/ui/react-app/src/pages/PanelList.tsx +++ b/web/ui/react-app/src/pages/PanelList.tsx @@ -7,14 +7,13 @@ import Panel, { PanelOptions, PanelDefaultOptions } from '../Panel'; import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from '../utils/urlParams'; import Checkbox from '../Checkbox'; import PathPrefixProps from '../PathPrefixProps'; +import { generateID } from '../utils/func'; export type MetricGroup = { title: string; items: string[] }; +export type PanelMeta = { key: string; options: PanelOptions; id: string }; interface PanelListState { - panels: { - key: string; - options: PanelOptions; - }[]; + panels: PanelMeta[]; pastQueries: string[]; metricNames: string[]; fetchMetricsError: string | null; @@ -22,22 +21,11 @@ interface PanelListState { } class PanelList extends Component { - private key = 0; - constructor(props: PathPrefixProps) { + constructor(props: RouteComponentProps & PathPrefixProps) { super(props); - const urlPanels = decodePanelOptionsFromQueryString(window.location.search); - this.state = { - panels: - urlPanels.length !== 0 - ? urlPanels - : [ - { - key: this.getKey(), - options: PanelDefaultOptions, - }, - ], + panels: decodePanelOptionsFromQueryString(window.location.search), pastQueries: [], metricNames: [], fetchMetricsError: null, @@ -46,6 +34,7 @@ class PanelList extends Component { if (resp.ok) { @@ -84,8 +73,8 @@ class PanelList extends Component { const panels = decodePanelOptionsFromQueryString(window.location.search); - if (panels.length !== 0) { - this.setState({ panels: panels }); + if (panels.length > 0) { + this.setState({ panels }); } }; @@ -123,46 +112,42 @@ class PanelList extends Component { - if (key === p.key) { - return { - key: key, - options: opts, - }; - } - return p; - }); - this.setState({ panels: newPanels }, this.updateURL); - } - - updateURL(): void { + updateURL() { const query = encodePanelOptionsToQueryString(this.state.panels); window.history.pushState({}, '', query); } - addPanel = (): void => { - const panels = this.state.panels.slice(); - panels.push({ - key: this.getKey(), - options: PanelDefaultOptions, - }); - this.setState({ panels: panels }, this.updateURL); + handleOptionsChanged = (id: string, options: PanelOptions) => { + const updatedPanels = this.state.panels.map(p => (id === p.id ? { ...p, options } : p)); + this.setState({ panels: updatedPanels }, this.updateURL); }; - removePanel = (key: string): void => { - const panels = this.state.panels.filter(panel => { - return panel.key !== key; - }); - this.setState({ panels: panels }, this.updateURL); + addPanel = () => { + const { panels } = this.state; + const nextPanels = [ + ...panels, + { + id: generateID(), + key: `${panels.length}`, + options: PanelDefaultOptions, + }, + ]; + this.setState({ panels: nextPanels }, this.updateURL); + }; + + removePanel = (id: string) => { + this.setState( + { + panels: this.state.panels.reduce((acc, panel) => { + return panel.id !== id ? [...acc, { ...panel, key: `${acc.length}` }] : acc; + }, []), + }, + this.updateURL + ); }; render() { - const { metricNames, pastQueries, timeDriftError, fetchMetricsError } = this.state; + const { metricNames, pastQueries, timeDriftError, fetchMetricsError, panels } = this.state; const { pathPrefix } = this.props; return ( <> @@ -180,7 +165,7 @@ class PanelList extends Component {timeDriftError && ( - Warning: Error fetching server time: {this.state.timeDriftError} + Warning: Error fetching server time: {timeDriftError} )} @@ -189,18 +174,18 @@ class PanelList extends Component {fetchMetricsError && ( - Warning: Error fetching metrics list: {this.state.fetchMetricsError} + Warning: Error fetching metrics list: {fetchMetricsError} )} - {this.state.panels.map(p => ( + {panels.map(({ id, options }) => ( this.handleOptionsChanged(p.key, opts)} - removePanel={() => this.removePanel(p.key)} + key={id} + options={options} + onOptionsChanged={opts => this.handleOptionsChanged(id, opts)} + removePanel={() => this.removePanel(id)} metricNames={metricNames} pastQueries={pastQueries} pathPrefix={pathPrefix} diff --git a/web/ui/react-app/src/utils/func.ts b/web/ui/react-app/src/utils/func.ts new file mode 100644 index 0000000000..4da29cc9f3 --- /dev/null +++ b/web/ui/react-app/src/utils/func.ts @@ -0,0 +1,9 @@ +export const generateID = () => { + return `_${Math.random() + .toString(36) + .substr(2, 9)}`; +}; + +export const byEmptyString = (p: string) => p.length > 0; + +export const isPresent = (obj: T): obj is NonNullable => obj !== null && obj !== undefined; diff --git a/web/ui/react-app/src/utils/urlParams.test.ts b/web/ui/react-app/src/utils/urlParams.test.ts index d117796614..dffcbd328f 100644 --- a/web/ui/react-app/src/utils/urlParams.test.ts +++ b/web/ui/react-app/src/utils/urlParams.test.ts @@ -1,7 +1,8 @@ -import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './urlParams'; +import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString, parseOption, toQueryString } from './urlParams'; import { PanelType } from '../Panel'; +import moment from 'moment'; -const panels = [ +const panels: any = [ { key: '0', options: { @@ -33,7 +34,61 @@ describe('decodePanelOptionsFromQueryString', () => { expect(decodePanelOptionsFromQueryString('')).toEqual([]); }); it('returns and array of parsed params when query string is non-empty', () => { - expect(decodePanelOptionsFromQueryString(query)).toEqual(panels); + expect(decodePanelOptionsFromQueryString(query)).toMatchObject(panels); + }); +}); + +describe('parseOption', () => { + it('should return empty object for invalid param', () => { + expect(parseOption('invalid_prop=foo')).toEqual({}); + }); + it('should parse expr param', () => { + expect(parseOption('expr=foo')).toEqual({ expr: 'foo' }); + }); + it('should parse stacked', () => { + expect(parseOption('stacked=1')).toEqual({ stacked: true }); + }); + it('should parse end_input', () => { + expect(parseOption('end_input=2019-10-25%2023%3A37')).toEqual({ endTime: moment.utc('2019-10-25 23:37').valueOf() }); + }); + it('should parse moment_input', () => { + expect(parseOption('moment_input=2019-10-25%2023%3A37')).toEqual({ endTime: moment.utc('2019-10-25 23:37').valueOf() }); + }); + describe('step_input', () => { + it('should return step_input parsed if > 0', () => { + expect(parseOption('step_input=2')).toEqual({ resolution: 2 }); + }); + it('should return empty object if step is equal 0', () => { + expect(parseOption('step_input=0')).toEqual({}); + }); + }); + describe('range_input', () => { + it('should return range parsed if its not null', () => { + expect(parseOption('range_input=2h')).toEqual({ range: 7200 }); + }); + it('should return empty object for invalid value', () => { + expect(parseOption('range_input=h')).toEqual({}); + }); + }); + describe('Parse type param', () => { + it('should return panel type "graph" if tab=0', () => { + expect(parseOption('tab=0')).toEqual({ type: PanelType.Graph }); + }); + it('should return panel type "table" if tab=1', () => { + expect(parseOption('tab=1')).toEqual({ type: PanelType.Table }); + }); + }); +}); + +describe('toQueryString', () => { + it('should generate query string from panel options', () => { + expect( + toQueryString({ + id: 'asdf', + key: '0', + options: { expr: 'foo', type: PanelType.Graph, stacked: true, range: 0, endTime: null, resolution: 1 }, + }) + ).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.range_input=0y&g0.step_input=1'); }); }); diff --git a/web/ui/react-app/src/utils/urlParams.ts b/web/ui/react-app/src/utils/urlParams.ts index 38cc62813e..affeb6e9dd 100644 --- a/web/ui/react-app/src/utils/urlParams.ts +++ b/web/ui/react-app/src/utils/urlParams.ts @@ -1,127 +1,85 @@ import { parseRange, parseTime, formatRange, formatTime } from './timeFormat'; import { PanelOptions, PanelType, PanelDefaultOptions } from '../Panel'; - -export function decodePanelOptionsFromQueryString(query: string): { key: string; options: PanelOptions }[] { - if (query === '') { - return []; - } - - const params = query.substring(1).split('&'); - return parseParams(params); -} +import { generateID, byEmptyString, isPresent } from './func'; +import { PanelMeta } from '../pages/PanelList'; const paramFormat = /^g\d+\..+=.+$/; -interface IncompletePanelOptions { - expr?: string; - type?: PanelType; - range?: number; - endTime?: number | null; - resolution?: number | null; - stacked?: boolean; -} - -function parseParams(params: string[]): { key: string; options: PanelOptions }[] { - const sortedParams = params - .filter(p => { - return paramFormat.test(p); - }) - .sort(); - - const panelOpts: { key: string; options: PanelOptions }[] = []; - - let key = 0; - let options: IncompletePanelOptions = {}; - for (const p of sortedParams) { - const prefix = 'g' + key + '.'; - - if (!p.startsWith(prefix)) { - panelOpts.push({ - key: key.toString(), - options: { ...PanelDefaultOptions, ...options }, - }); - options = {}; - key++; - } - - addParam(options, p.substring(prefix.length)); +export const decodePanelOptionsFromQueryString = (query: string): PanelMeta[] => { + if (query === '') { + return []; } - panelOpts.push({ - key: key.toString(), - options: { ...PanelDefaultOptions, ...options }, - }); + const urlParams = query.substring(1).split('&'); - return panelOpts; -} - -function addParam(opts: IncompletePanelOptions, param: string): void { - let [opt, val] = param.split('='); - val = decodeURIComponent(val.replace(/\+/g, ' ')); + return urlParams.reduce((panels, urlParam, i) => { + const panelsCount = panels.length; + const prefix = `g${panelsCount}.`; + if (urlParam.startsWith(`${prefix}expr=`)) { + const prefixLen = prefix.length; + return [ + ...panels, + { + id: generateID(), + key: `${panelsCount}`, + options: urlParams.slice(i).reduce((opts, param) => { + return param.startsWith(prefix) && paramFormat.test(param) + ? { ...opts, ...parseOption(param.substring(prefixLen)) } + : opts; + }, PanelDefaultOptions), + }, + ]; + } + return panels; + }, []); +}; +export const parseOption = (param: string): Partial => { + const [opt, val] = param.split('='); + const decodedValue = decodeURIComponent(val.replace(/\+/g, ' ')); switch (opt) { case 'expr': - opts.expr = val; - break; + return { expr: decodedValue }; case 'tab': - if (val === '0') { - opts.type = PanelType.Graph; - } else { - opts.type = PanelType.Table; - } - break; + return { type: decodedValue === '0' ? PanelType.Graph : PanelType.Table }; case 'stacked': - opts.stacked = val === '1'; - break; + return { stacked: decodedValue === '1' }; case 'range_input': - const range = parseRange(val); - if (range !== null) { - opts.range = range; - } - break; + const range = parseRange(decodedValue); + return isPresent(range) ? { range } : {}; case 'end_input': - opts.endTime = parseTime(val); - break; + case 'moment_input': + return { endTime: parseTime(decodedValue) }; case 'step_input': - const res = parseInt(val); - if (res > 0) { - opts.resolution = res; - } - break; - - case 'moment_input': - opts.endTime = parseTime(val); - break; + const resolution = parseInt(decodedValue); + return resolution > 0 ? { resolution } : {}; } -} + return {}; +}; -export function encodePanelOptionsToQueryString(panels: { key: string; options: PanelOptions }[]): string { - const queryParams: string[] = []; +export const formatParam = (key: string) => (paramName: string, value: number | string | boolean) => { + return `g${key}.${paramName}=${encodeURIComponent(value)}`; +}; - panels.forEach(p => { - const prefix = 'g' + p.key + '.'; - const o = p.options; - const panelParams: { [key: string]: string | undefined } = { - expr: o.expr, - tab: o.type === PanelType.Graph ? '0' : '1', - stacked: o.stacked ? '1' : '0', - range_input: formatRange(o.range), - end_input: o.endTime !== null ? formatTime(o.endTime) : undefined, - moment_input: o.endTime !== null ? formatTime(o.endTime) : undefined, - step_input: o.resolution !== null ? o.resolution.toString() : undefined, - }; +export const toQueryString = ({ key, options }: PanelMeta) => { + const formatWithKey = formatParam(key); + const { expr, type, stacked, range, endTime, resolution } = options; + const time = isPresent(endTime) ? formatTime(endTime) : false; + const urlParams = [ + formatWithKey('expr', expr), + formatWithKey('tab', type === PanelType.Graph ? 0 : 1), + formatWithKey('stacked', stacked ? 1 : 0), + formatWithKey('range_input', formatRange(range)), + time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '', + isPresent(resolution) ? formatWithKey('step_input', resolution) : '', + ]; + return urlParams.filter(byEmptyString).join('&'); +}; - for (const o in panelParams) { - const pp = panelParams[o]; - if (pp !== undefined) { - queryParams.push(prefix + o + '=' + encodeURIComponent(pp)); - } - } - }); - - return '?' + queryParams.join('&'); -} +export const encodePanelOptionsToQueryString = (panels: PanelMeta[]) => { + return `?${panels.map(toQueryString).join('&')}`; +};