PanelQueryRunner: Return previous processed (transform+field config) series for loading state (#67768)
* Return same series for loading state * Fix shared query issue * include structureRev * heatmap should depend on the series, not the wrapper * fix more panels * keep config for comparison * fieldConfig.fieldConfig! * cleanup * cmon --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com> Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
co-authored by
Leon Sorokin
Ryan McKinley
parent
69a38def63
commit
32fafffed7
+1
-2
@@ -5594,8 +5594,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||||
[0, 0, 0, "Do not use any type assertions.", "4"]
|
|
||||||
],
|
],
|
||||||
"public/app/plugins/panel/heatmap/palettes.ts:5381": [
|
"public/app/plugins/panel/heatmap/palettes.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import { MonoTypeOperatorFunction, Observable, of, ReplaySubject, Unsubscribable } from 'rxjs';
|
import { Observable, of, ReplaySubject, Unsubscribable } from 'rxjs';
|
||||||
import { map, mergeMap } from 'rxjs/operators';
|
import { map, mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
toDataFrame,
|
toDataFrame,
|
||||||
transformDataFrame,
|
transformDataFrame,
|
||||||
preProcessPanelData,
|
preProcessPanelData,
|
||||||
|
ApplyFieldOverrideOptions,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getTemplateSrv, toDataQueryError } from '@grafana/runtime';
|
import { getTemplateSrv, toDataQueryError } from '@grafana/runtime';
|
||||||
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
||||||
@@ -91,7 +92,9 @@ export class PanelQueryRunner {
|
|||||||
getData(options: GetDataOptions): Observable<PanelData> {
|
getData(options: GetDataOptions): Observable<PanelData> {
|
||||||
const { withFieldConfig, withTransforms } = options;
|
const { withFieldConfig, withTransforms } = options;
|
||||||
let structureRev = 1;
|
let structureRev = 1;
|
||||||
let lastData: DataFrame[] = [];
|
let lastFieldConfig: ApplyFieldOverrideOptions | undefined = undefined;
|
||||||
|
let lastProcessedFrames: DataFrame[] = [];
|
||||||
|
let lastRawFrames: DataFrame[] = [];
|
||||||
let isFirstPacket = true;
|
let isFirstPacket = true;
|
||||||
let lastConfigRev = -1;
|
let lastConfigRev = -1;
|
||||||
|
|
||||||
@@ -105,97 +108,103 @@ export class PanelQueryRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.subject.pipe(
|
return this.subject.pipe(
|
||||||
this.getTransformationsStream(withTransforms),
|
mergeMap((data: PanelData) => {
|
||||||
map((data: PanelData) => {
|
let fieldConfig = this.dataConfigSource.getFieldOverrideOptions();
|
||||||
let processedData = data;
|
|
||||||
let streamingPacketWithSameSchema = false;
|
|
||||||
|
|
||||||
if (withFieldConfig && data.series?.length) {
|
if (data.series === lastRawFrames && lastFieldConfig?.fieldConfig === fieldConfig?.fieldConfig) {
|
||||||
if (lastConfigRev === this.dataConfigSource.configRev) {
|
return of({ ...data, structureRev, series: lastProcessedFrames });
|
||||||
const streamingDataFrame = data.series.find((data) => isStreamingDataFrame(data)) as
|
}
|
||||||
| StreamingDataFrame
|
|
||||||
| undefined;
|
lastFieldConfig = fieldConfig;
|
||||||
|
lastRawFrames = data.series;
|
||||||
|
let dataWithTransforms = of(data);
|
||||||
|
|
||||||
|
if (withTransforms) {
|
||||||
|
dataWithTransforms = this.applyTransformations(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataWithTransforms.pipe(
|
||||||
|
map((data: PanelData) => {
|
||||||
|
let processedData = data;
|
||||||
|
let streamingPacketWithSameSchema = false;
|
||||||
|
|
||||||
|
if (withFieldConfig && data.series?.length) {
|
||||||
|
if (lastConfigRev === this.dataConfigSource.configRev) {
|
||||||
|
const streamingDataFrame = data.series.find((data) => isStreamingDataFrame(data)) as
|
||||||
|
| StreamingDataFrame
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
streamingDataFrame &&
|
||||||
|
!streamingDataFrame.packetInfo.schemaChanged &&
|
||||||
|
// TODO: remove the condition below after fixing
|
||||||
|
// https://github.com/grafana/grafana/pull/41492#issuecomment-970281430
|
||||||
|
lastProcessedFrames[0].fields.length === streamingDataFrame.fields.length
|
||||||
|
) {
|
||||||
|
processedData = {
|
||||||
|
...processedData,
|
||||||
|
series: lastProcessedFrames.map((frame, frameIndex) => ({
|
||||||
|
...frame,
|
||||||
|
length: data.series[frameIndex].length,
|
||||||
|
fields: frame.fields.map((field, fieldIndex) => ({
|
||||||
|
...field,
|
||||||
|
values: data.series[frameIndex].fields[fieldIndex].values,
|
||||||
|
state: {
|
||||||
|
...field.state,
|
||||||
|
calcs: undefined,
|
||||||
|
range: undefined,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingPacketWithSameSchema = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldConfig != null && (isFirstPacket || !streamingPacketWithSameSchema)) {
|
||||||
|
lastConfigRev = this.dataConfigSource.configRev!;
|
||||||
|
processedData = {
|
||||||
|
...processedData,
|
||||||
|
series: applyFieldOverrides({
|
||||||
|
timeZone: data.request?.timezone ?? 'browser',
|
||||||
|
data: processedData.series,
|
||||||
|
...fieldConfig!,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
isFirstPacket = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
streamingDataFrame &&
|
!streamingPacketWithSameSchema &&
|
||||||
!streamingDataFrame.packetInfo.schemaChanged &&
|
!compareArrayValues(lastProcessedFrames, processedData.series, compareDataFrameStructures)
|
||||||
// TODO: remove the condition below after fixing
|
|
||||||
// https://github.com/grafana/grafana/pull/41492#issuecomment-970281430
|
|
||||||
lastData[0].fields.length === streamingDataFrame.fields.length
|
|
||||||
) {
|
) {
|
||||||
processedData = {
|
structureRev++;
|
||||||
...processedData,
|
|
||||||
series: lastData.map((frame, frameIndex) => ({
|
|
||||||
...frame,
|
|
||||||
length: data.series[frameIndex].length,
|
|
||||||
fields: frame.fields.map((field, fieldIndex) => ({
|
|
||||||
...field,
|
|
||||||
values: data.series[frameIndex].fields[fieldIndex].values,
|
|
||||||
state: {
|
|
||||||
...field.state,
|
|
||||||
calcs: undefined,
|
|
||||||
range: undefined,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
streamingPacketWithSameSchema = true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Apply field defaults and overrides
|
lastProcessedFrames = processedData.series;
|
||||||
let fieldConfig = this.dataConfigSource.getFieldOverrideOptions();
|
|
||||||
|
|
||||||
if (fieldConfig != null && (isFirstPacket || !streamingPacketWithSameSchema)) {
|
return { ...processedData, structureRev };
|
||||||
lastConfigRev = this.dataConfigSource.configRev!;
|
})
|
||||||
processedData = {
|
);
|
||||||
...processedData,
|
|
||||||
series: applyFieldOverrides({
|
|
||||||
timeZone: data.request?.timezone ?? 'browser',
|
|
||||||
data: processedData.series,
|
|
||||||
...fieldConfig!,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
isFirstPacket = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!streamingPacketWithSameSchema &&
|
|
||||||
!compareArrayValues(lastData, processedData.series, compareDataFrameStructures)
|
|
||||||
) {
|
|
||||||
structureRev++;
|
|
||||||
}
|
|
||||||
lastData = processedData.series;
|
|
||||||
|
|
||||||
return { ...processedData, structureRev };
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTransformationsStream = (withTransforms: boolean): MonoTypeOperatorFunction<PanelData> => {
|
private applyTransformations(data: PanelData): Observable<PanelData> {
|
||||||
return (inputStream) =>
|
const transformations = this.dataConfigSource.getTransformations();
|
||||||
inputStream.pipe(
|
|
||||||
mergeMap((data) => {
|
|
||||||
if (!withTransforms) {
|
|
||||||
return of(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformations = this.dataConfigSource.getTransformations();
|
if (!transformations || transformations.length === 0) {
|
||||||
|
return of(data);
|
||||||
|
}
|
||||||
|
|
||||||
if (!transformations || transformations.length === 0) {
|
const ctx: DataTransformContext = {
|
||||||
return of(data);
|
interpolate: (v: string) => getTemplateSrv().replace(v, data?.request?.scopedVars),
|
||||||
}
|
};
|
||||||
|
|
||||||
const ctx: DataTransformContext = {
|
return transformDataFrame(transformations, data.series, ctx).pipe(map((series) => ({ ...data, series })));
|
||||||
interpolate: (v: string) => getTemplateSrv().replace(v, data?.request?.scopedVars),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return transformDataFrame(transformations, data.series, ctx).pipe(map((series) => ({ ...data, series })));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
async run(options: QueryRunnerOptions) {
|
async run(options: QueryRunnerOptions) {
|
||||||
const {
|
const {
|
||||||
@@ -216,7 +225,7 @@ export class PanelQueryRunner {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (isSharedDashboardQuery(datasource)) {
|
if (isSharedDashboardQuery(datasource)) {
|
||||||
this.pipeToSubject(runSharedRequest(options, queries[0]), panelId);
|
this.pipeToSubject(runSharedRequest(options, queries[0]), panelId, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +293,7 @@ export class PanelQueryRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pipeToSubject(observable: Observable<PanelData>, panelId?: number) {
|
private pipeToSubject(observable: Observable<PanelData>, panelId?: number, skipPreProcess = false) {
|
||||||
if (this.subscription) {
|
if (this.subscription) {
|
||||||
this.subscription.unsubscribe();
|
this.subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
@@ -299,7 +308,7 @@ export class PanelQueryRunner {
|
|||||||
|
|
||||||
this.subscription = panelData.subscribe({
|
this.subscription = panelData.subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.lastResult = preProcessPanelData(data, this.lastResult);
|
this.lastResult = skipPreProcess ? data : preProcessPanelData(data, this.lastResult);
|
||||||
// Store preprocessed query results for applying overrides later on in the pipeline
|
// Store preprocessed query results for applying overrides later on in the pipeline
|
||||||
this.subject.next(this.lastResult);
|
this.subject.next(this.lastResult);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ
|
|||||||
const frame0Ref = useRef<DataFrame>();
|
const frame0Ref = useRef<DataFrame>();
|
||||||
const colorByFieldRef = useRef<Field>();
|
const colorByFieldRef = useRef<Field>();
|
||||||
|
|
||||||
const info = useMemo(() => prepareBarChartDisplayValues(data?.series, theme, options), [data, theme, options]);
|
const info = useMemo(() => prepareBarChartDisplayValues(data.series, theme, options), [data.series, theme, options]);
|
||||||
const chartDisplay = 'viz' in info ? info : null;
|
const chartDisplay = 'viz' in info ? info : null;
|
||||||
|
|
||||||
colorByFieldRef.current = chartDisplay?.colorByField;
|
colorByFieldRef.current = chartDisplay?.colorByField;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const CandlestickPanel = ({
|
|||||||
|
|
||||||
const info = useMemo(() => {
|
const info = useMemo(() => {
|
||||||
return prepareCandlestickFields(data.series, options, theme, timeRange);
|
return prepareCandlestickFields(data.series, options, theme, timeRange);
|
||||||
}, [data, options, theme, timeRange]);
|
}, [data.series, options, theme, timeRange]);
|
||||||
|
|
||||||
const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => {
|
const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => {
|
||||||
let tweakScale = (opts: ScaleProps, forField: Field) => opts;
|
let tweakScale = (opts: ScaleProps, forField: Field) => opts;
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export const HeatmapPanel = ({
|
|||||||
|
|
||||||
const info = useMemo(() => {
|
const info = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return prepareHeatmapData(data, options, theme, getFieldLinksSupplier);
|
return prepareHeatmapData(data.series, data.annotations, options, theme, getFieldLinksSupplier);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
return { warning: `${ex}` };
|
return { warning: `${ex}` };
|
||||||
}
|
}
|
||||||
}, [data, options, theme, getFieldLinksSupplier]);
|
}, [data.series, data.annotations, options, theme, getFieldLinksSupplier]);
|
||||||
|
|
||||||
const facets = useMemo(() => {
|
const facets = useMemo(() => {
|
||||||
let exemplarsXFacet: number[] = []; // "Time" field
|
let exemplarsXFacet: number[] = []; // "Time" field
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
LinkModel,
|
LinkModel,
|
||||||
outerJoinDataFrames,
|
outerJoinDataFrames,
|
||||||
PanelData,
|
|
||||||
ValueFormatter,
|
ValueFormatter,
|
||||||
ValueLinkConfig,
|
ValueLinkConfig,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
@@ -56,17 +55,17 @@ export interface HeatmapData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function prepareHeatmapData(
|
export function prepareHeatmapData(
|
||||||
data: PanelData,
|
frames: DataFrame[],
|
||||||
|
annotations: DataFrame[] | undefined,
|
||||||
options: PanelOptions,
|
options: PanelOptions,
|
||||||
theme: GrafanaTheme2,
|
theme: GrafanaTheme2,
|
||||||
getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array<LinkModel<Field>>
|
getFieldLinks?: (exemplars: DataFrame, field: Field) => (config: ValueLinkConfig) => Array<LinkModel<Field>>
|
||||||
): HeatmapData {
|
): HeatmapData {
|
||||||
let frames = data.series;
|
|
||||||
if (!frames?.length) {
|
if (!frames?.length) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const exemplars = data.annotations?.find((f) => f.name === 'exemplar');
|
const exemplars = annotations?.find((f) => f.name === 'exemplar');
|
||||||
|
|
||||||
if (getFieldLinks) {
|
if (getFieldLinks) {
|
||||||
exemplars?.fields.forEach((field, index) => {
|
exemplars?.fields.forEach((field, index) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { FieldConfigProperty, FieldType, identityOverrideProcessor, PanelData, PanelPlugin } from '@grafana/data';
|
import { FieldConfigProperty, FieldType, identityOverrideProcessor, PanelPlugin } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
AxisPlacement,
|
AxisPlacement,
|
||||||
@@ -48,7 +48,7 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
|||||||
let isOrdinalY = false;
|
let isOrdinalY = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const v = prepareHeatmapData({ series: context.data } as PanelData, opts, config.theme2);
|
const v = prepareHeatmapData(context.data, undefined, opts, config.theme2);
|
||||||
isOrdinalY = readHeatmapRowsCustomMeta(v.heatmap).yOrdinalDisplay != null;
|
isOrdinalY = readHeatmapRowsCustomMeta(v.heatmap).yOrdinalDisplay != null;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class HeatmapSuggestionsSupplier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = prepareHeatmapData(builder.data, defaultPanelOptions, config.theme2);
|
const info = prepareHeatmapData(builder.data.series, undefined, defaultPanelOptions, config.theme2);
|
||||||
if (!info || info.warning) {
|
if (!info || info.warning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const HistogramPanel = ({ data, options, width, height }: Props) => {
|
|||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
|
|
||||||
const histogram = useMemo(() => {
|
const histogram = useMemo(() => {
|
||||||
if (!data?.series?.length) {
|
if (!data.series.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export const StateTimelinePanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { frames, warn } = useMemo(
|
const { frames, warn } = useMemo(
|
||||||
() => prepareTimelineFields(data?.series, options.mergeValues ?? true, timeRange, theme),
|
() => prepareTimelineFields(data.series, options.mergeValues ?? true, timeRange, theme),
|
||||||
[data, options.mergeValues, timeRange, theme]
|
[data.series, options.mergeValues, timeRange, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
const legendItems = useMemo(
|
const legendItems = useMemo(
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export const StatusHistoryPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { frames, warn } = useMemo(
|
const { frames, warn } = useMemo(
|
||||||
() => prepareTimelineFields(data?.series, false, timeRange, theme),
|
() => prepareTimelineFields(data.series, false, timeRange, theme),
|
||||||
[data, timeRange, theme]
|
[data.series, timeRange, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
const legendItems = useMemo(
|
const legendItems = useMemo(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const TimeSeriesPanel = ({
|
|||||||
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
||||||
};
|
};
|
||||||
|
|
||||||
const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data, timeRange]);
|
const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data.series, timeRange]);
|
||||||
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
|
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
|
||||||
const suggestions = useMemo(() => {
|
const suggestions = useMemo(() => {
|
||||||
if (frames?.length && frames.every((df) => df.meta?.type === DataFrameType.TimeSeriesLong)) {
|
if (frames?.length && frames.every((df) => df.meta?.type === DataFrameType.TimeSeriesLong)) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const TrendPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
|
return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) };
|
||||||
}, [data, options.xField]);
|
}, [data.series, options.xField]);
|
||||||
|
|
||||||
if (info.warning || !info.frames) {
|
if (info.warning || !info.frames) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const XYChartPanel2 = (props: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (oldOptions !== props.options || oldData?.structureRev !== props.data.structureRev) {
|
if (oldOptions !== props.options || oldData?.structureRev !== props.data.structureRev) {
|
||||||
initSeries();
|
initSeries();
|
||||||
} else if (oldData !== props.data) {
|
} else if (oldData?.series !== props.data.series) {
|
||||||
initFacets();
|
initFacets();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
Reference in New Issue
Block a user