mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 17:30:34 +01:00
Compare commits
2 Commits
feat/dashb
...
chore/oxfm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e523d239d2 | ||
|
|
5cf0e0fbb9 |
@@ -23,6 +23,11 @@
|
||||
"**/*.md",
|
||||
"**/*.json",
|
||||
"src/parser/**",
|
||||
"src/TraceOperator/parser/**"
|
||||
"src/TraceOperator/parser/**",
|
||||
".claude",
|
||||
".opencode",
|
||||
"dist",
|
||||
"playwright-report",
|
||||
".temp_cache"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
"vite-plugin-image-optimizer": "2.0.3",
|
||||
|
||||
@@ -38,5 +38,4 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSync {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
.overview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.overview-settings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
|
||||
.name-icon-input {
|
||||
display: flex;
|
||||
.dashboard-image-input {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name-input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.description-text-area {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-settings-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.unsaved-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
.unsaved-changes {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.footer-action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.discard-btn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-image-input {
|
||||
&.ant-select-dropdown {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Select, Space, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
function GeneralDashboardSettings(): JSX.Element {
|
||||
@@ -20,10 +19,6 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -105,8 +100,8 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<div className="overview-content">
|
||||
<Col className="overview-settings">
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
@@ -117,29 +112,27 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Dashboard Name
|
||||
</Typography>
|
||||
<section className="name-icon-input">
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
rootClassName="dashboard-image-input"
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
<img src={icon} alt="dashboard-icon" className="list-item-image" />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
@@ -147,59 +140,41 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Description
|
||||
</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
className="description-text-area"
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Tags
|
||||
</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSync}`}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
<div className="unsaved-dot" />
|
||||
<Typography.Text className="unsaved-changes">
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<div className="footer-action-btns">
|
||||
<Button
|
||||
disabled={updateDashboardMutation.isLoading}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
className="discard-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -213,7 +188,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
className="save-btn"
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -36,9 +34,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -80,11 +75,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -132,7 +122,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={syncMode}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -149,7 +138,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={syncMode}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -3,12 +3,10 @@ import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSe
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -35,9 +33,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -86,11 +81,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
@@ -123,7 +113,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={syncMode}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -136,7 +125,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
groupBy={groupBy}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={syncMode}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
<ContextMenu
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -190,16 +187,19 @@ function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(() => {
|
||||
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
|
||||
|
||||
return {
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime],
|
||||
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -16,10 +16,7 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -114,6 +111,9 @@ export function K8sBaseList<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(
|
||||
@@ -127,6 +127,7 @@ export function K8sBaseList<T>({
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
pageSize,
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from 'antd';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
@@ -118,6 +115,9 @@ export function K8sExpandedRow<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(selectedTime, [
|
||||
@@ -126,7 +126,7 @@ export function K8sExpandedRow<T>({
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
]);
|
||||
}, [selectedTime, record.key, queryFilters, orderBy]);
|
||||
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import {
|
||||
useGlobalTimeQueryInvalidate,
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'hooks/globalTime';
|
||||
} from 'store/globalTime';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -101,14 +101,14 @@ function DateTimeSelection({
|
||||
if (modalInitialStartTime !== undefined) {
|
||||
initialModalStartTime = modalInitialStartTime;
|
||||
} else if (searchStartTime) {
|
||||
initialModalStartTime = parseInt(searchStartTime, 10);
|
||||
initialModalStartTime = Number.parseInt(searchStartTime, 10);
|
||||
}
|
||||
|
||||
let initialModalEndTime = 0;
|
||||
if (modalInitialEndTime !== undefined) {
|
||||
initialModalEndTime = modalInitialEndTime;
|
||||
} else if (searchEndTime) {
|
||||
initialModalEndTime = parseInt(searchEndTime, 10);
|
||||
initialModalEndTime = Number.parseInt(searchEndTime, 10);
|
||||
}
|
||||
|
||||
const [modalStartTime, setModalStartTime] = useState<number>(
|
||||
@@ -159,9 +159,11 @@ function DateTimeSelection({
|
||||
const getTime = useCallback((): [number, number] | undefined => {
|
||||
if (searchEndTime && searchStartTime) {
|
||||
const startDate = dayjs(
|
||||
new Date(parseInt(getTimeString(searchStartTime), 10)),
|
||||
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(
|
||||
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
|
||||
|
||||
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
|
||||
import { useDashboardPreferencesStore } from '../useDashboardPreference';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
|
||||
describe('useDashboardCursorSyncMode', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('in DASHBOARD_VIEW mode', () => {
|
||||
it('uses Crosshair as the default cursor sync mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('reads the stored cursor sync mode for the dashboard', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes the value under the cursorSyncMode key in the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the value to localStorage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||
expect(persisted.state.preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
|
||||
'in %s mode (cursor sync disabled)',
|
||||
(panelMode) => {
|
||||
it('returns the Crosshair default and ignores any stored value', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op and does not write to the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import {
|
||||
useDashboardPreference,
|
||||
useDashboardPreferencesStore,
|
||||
} from '../useDashboardPreference';
|
||||
|
||||
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
describe('useDashboardPreference', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('returns the default value when no preference is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the default value when dashboardId is undefined', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the stored value for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the new value via the setter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('keeps multiple hook instances in sync after a write', () => {
|
||||
const { result: writer } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: reader } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
writer.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('isolates preferences across different dashboardIds', () => {
|
||||
const { result: dashOne } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: dashTwo } = renderHook(() =>
|
||||
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dashOne.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('does not overwrite preferences for other dashboards when writing', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
const DEFAULT_CURSOR_SYNC_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
export function useDashboardCursorSyncMode(
|
||||
dashboardId: string | undefined,
|
||||
panelMode?: PanelMode,
|
||||
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
|
||||
if (panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
// Only allow cursor sync modes to be set in the dashboard view mode, as the other modes are used for editing and standalone panel views where cursor sync is not applicable.
|
||||
return [DEFAULT_CURSOR_SYNC_MODE, () => {}];
|
||||
}
|
||||
|
||||
return useDashboardPreference(
|
||||
dashboardId,
|
||||
'cursorSyncMode',
|
||||
DEFAULT_CURSOR_SYNC_MODE,
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
// Per-dashboard preferences persisted in localStorage. Add new preference
|
||||
// fields here as they are introduced.
|
||||
export type DashboardPreferences = {
|
||||
cursorSyncMode?: DashboardCursorSync;
|
||||
};
|
||||
|
||||
interface DashboardPreferencesState {
|
||||
preferences: Record<string, DashboardPreferences>;
|
||||
setPreference: <K extends keyof DashboardPreferences>(
|
||||
dashboardId: string,
|
||||
key: K,
|
||||
value: NonNullable<DashboardPreferences[K]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: {},
|
||||
setPreference: (dashboardId, key, value): void => {
|
||||
set((state) => ({
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
[dashboardId]: {
|
||||
...state.preferences[dashboardId],
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
|
||||
),
|
||||
);
|
||||
|
||||
export function useDashboardPreference<K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
defaultValue: NonNullable<DashboardPreferences[K]>,
|
||||
): [
|
||||
NonNullable<DashboardPreferences[K]>,
|
||||
(value: NonNullable<DashboardPreferences[K]>) => void,
|
||||
] {
|
||||
type Value = NonNullable<DashboardPreferences[K]>;
|
||||
|
||||
const value = useDashboardPreferencesStore((state): Value => {
|
||||
if (!dashboardId) {
|
||||
return defaultValue;
|
||||
}
|
||||
return (
|
||||
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
|
||||
);
|
||||
});
|
||||
|
||||
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(next: Value): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
setPreference(dashboardId, key, next);
|
||||
},
|
||||
[dashboardId, key, setPreference],
|
||||
);
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(async () => {
|
||||
return await queryClient.invalidateQueries({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
});
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
return (
|
||||
useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
}) > 0
|
||||
);
|
||||
}
|
||||
@@ -30,11 +30,8 @@ function getCommonGroupByKeys(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every visible series whose metric matches
|
||||
* sourceMetric on all commonKeys. Hidden series (show === false) are
|
||||
* excluded — a hidden match contributes nothing to the receiver tooltip,
|
||||
* so treating it as "no match" lets the empty-array path suppress the
|
||||
* tooltip entirely instead of rendering an empty shell.
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
@@ -42,7 +39,7 @@ function findMatchingSeriesIndexes(
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0 || s.show === false) {
|
||||
if (i === 0) {
|
||||
return acc;
|
||||
}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
|
||||
import type {
|
||||
TooltipControllerState,
|
||||
TooltipSyncMetadata,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
const SYNC_KEY = 'test-sync';
|
||||
|
||||
function makeController(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
clickData: null,
|
||||
style: {},
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: true,
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePlot(
|
||||
series: ExtendedSeries[],
|
||||
cursorEvent: Record<string, unknown> | null = null,
|
||||
): uPlot {
|
||||
const root = document.createElement('div');
|
||||
const yCrosshair = document.createElement('div');
|
||||
yCrosshair.className = 'u-cursor-y';
|
||||
root.appendChild(yCrosshair);
|
||||
|
||||
return {
|
||||
root,
|
||||
series,
|
||||
cursor: { event: cursorEvent, left: 50 },
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY: BaseAutocompleteData = {
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
const groupByService: TooltipSyncMetadata = {
|
||||
groupBy: [SERVICE_NAME_KEY],
|
||||
};
|
||||
|
||||
function seedSourcePanel(activeMetric: Record<string, string>): void {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
|
||||
}
|
||||
|
||||
function makeReceiverSeries(
|
||||
entries: { name: string; show?: boolean }[],
|
||||
): ExtendedSeries[] {
|
||||
return [
|
||||
{} as ExtendedSeries,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
({
|
||||
show: e.show ?? true,
|
||||
metric: { 'service.name': e.name },
|
||||
}) as unknown as ExtendedSeries,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe('createSyncDisplayHook (receiver-side filtering)', () => {
|
||||
beforeEach(() => {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
|
||||
});
|
||||
|
||||
it('returns indexes of visible matching series only', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('treats all matching series being hidden as no match → empty array', () => {
|
||||
seedSourcePanel({ 'service.name': 'frontendproxy' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontendproxy', show: false },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
|
||||
});
|
||||
|
||||
it('excludes hidden series and keeps the visible matches', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: false },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
|
||||
// Focuses the first visible match, not the hidden one at index 1.
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('returns null (no filtering) when the hook runs on the source panel', () => {
|
||||
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
|
||||
// cursor.event != null marks this invocation as the source panel.
|
||||
const plot = makeFakePlot(series, { type: 'mousemove' });
|
||||
const controller = makeController();
|
||||
controller.focusedSeriesIndex = 1;
|
||||
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
|
||||
'service.name': 'flagd',
|
||||
});
|
||||
});
|
||||
});
|
||||
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
ReactNode,
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import get from 'api/browser/localstorage/get';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
GlobalTimeStoreApi,
|
||||
} from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
|
||||
import { usePersistence } from './usePersistence';
|
||||
import { useQueryCacheSync } from './useQueryCacheSync';
|
||||
import { useUrlSync } from './useUrlSync';
|
||||
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
|
||||
|
||||
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
|
||||
|
||||
export function GlobalTimeProvider({
|
||||
children,
|
||||
name,
|
||||
inheritGlobalTime = false,
|
||||
initialTime,
|
||||
enableUrlParams = false,
|
||||
removeQueryParamsOnUnmount = false,
|
||||
localStoragePersistKey,
|
||||
refreshInterval: initialRefreshInterval,
|
||||
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
|
||||
const parentStore = useContext(GlobalTimeContext);
|
||||
const globalStore = parentStore ?? defaultGlobalTimeStore;
|
||||
|
||||
const resolveInitialTime = (): GlobalTimeSelectedTime => {
|
||||
if (inheritGlobalTime) {
|
||||
return globalStore.getState().selectedTime;
|
||||
}
|
||||
if (localStoragePersistKey) {
|
||||
const stored = get(localStoragePersistKey);
|
||||
if (stored) {
|
||||
return stored as GlobalTimeSelectedTime;
|
||||
}
|
||||
}
|
||||
return initialTime ?? DEFAULT_TIME_RANGE;
|
||||
};
|
||||
|
||||
// Create isolated store (stable reference)
|
||||
const [store] = useState(() =>
|
||||
createGlobalTimeStore({
|
||||
name,
|
||||
selectedTime: resolveInitialTime(),
|
||||
refreshInterval: initialRefreshInterval ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
useComputedMinMaxSync(store);
|
||||
useQueryCacheSync(store);
|
||||
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
|
||||
usePersistence(store, localStoragePersistKey);
|
||||
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
jest.mock('api/browser/localstorage/set');
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
nuqsProps?: { searchParams?: string },
|
||||
) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('GlobalTimeProvider', () => {
|
||||
describe('name prop', () => {
|
||||
it('should pass name to store when provided', () => {
|
||||
const wrapper = createWrapper({ name: 'test-drawer' });
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('test-drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const wrapper = createWrapper({});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should create isolated store for each provider', () => {
|
||||
const wrapper1 = createWrapper({ initialTime: '1h' });
|
||||
const wrapper2 = createWrapper({ initialTime: '15m' });
|
||||
|
||||
const { result: result1 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper1 },
|
||||
);
|
||||
const { result: result2 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper2 },
|
||||
);
|
||||
|
||||
expect(result1.current).toBe('1h');
|
||||
expect(result2.current).toBe('15m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritGlobalTime', () => {
|
||||
it('should inherit time from parent store when inheritGlobalTime is true', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should inherit '6h' from parent provider
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should use initialTime when inheritGlobalTime is false', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should use its own initialTime, not parent's
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="?relativeTime=1h">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use inherited time when URL params are empty', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// No URL params, should keep inherited value
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should prefer custom time URL params over inheritGlobalTime', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
|
||||
>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// URL custom time params should override inherited time
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL sync', () => {
|
||||
it('should read relativeTime from URL on mount', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should read custom time from URL on mount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom URL keys when provided', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
relativeTimeKey: 'modalTime',
|
||||
},
|
||||
},
|
||||
{ searchParams: '?modalTime=3h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('3h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom startTimeKey and endTimeKey when provided', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
startTimeKey: 'customStart',
|
||||
endTimeKey: 'customEnd',
|
||||
},
|
||||
},
|
||||
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT read from URL when enableUrlParams is false', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: false, initialTime: '15m' },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should use initialTime, not URL value
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{
|
||||
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
// Should use startTime/endTime, not relativeTime
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use initialTime when URL has invalid time values', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true, initialTime: '15m' },
|
||||
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// parseAsInteger returns null for invalid values, so should fallback to initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify selectedTime is a custom time range string
|
||||
expect(result.current.selectedTime).toContain('||_||');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeQueryParamsOnUnmount', () => {
|
||||
const createUnmountTestWrapper = (
|
||||
getQueryString: () => string,
|
||||
setQueryString: (qs: string) => void,
|
||||
) => {
|
||||
return function TestWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const queryClient = createTestQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={getQueryString()}
|
||||
onUrlUpdate={(event): void => {
|
||||
setQueryString(event.queryString);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('relativeTime');
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount={false}>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick to ensure cleanup effects would have run
|
||||
await waitFor(() => {
|
||||
// URL params should still be present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom time URL params on unmount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('startTime');
|
||||
expect(currentQueryString).toContain('endTime');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom URL key params on unmount', async () => {
|
||||
let currentQueryString = 'modalTime=3h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider
|
||||
enableUrlParams={{
|
||||
relativeTimeKey: 'modalTime',
|
||||
}}
|
||||
removeQueryParamsOnUnmount
|
||||
>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('modalTime=3h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('modalTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when enableUrlParams is false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams={false} removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick
|
||||
await waitFor(() => {
|
||||
// URL params should still be present (enableUrlParams is false)
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
const mockSet = set as jest.MockedFunction<typeof set>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockSet.mockClear();
|
||||
mockSet.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should read from localStorage on mount', () => {
|
||||
localStorage.setItem('test-time-key', '6h');
|
||||
|
||||
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should write to localStorage on selectedTime change', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-persist-key',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('12h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT write to localStorage when persistKey is undefined', async () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
// Wait a tick to ensure any async operations complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toBe('1h');
|
||||
});
|
||||
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only write to localStorage when selectedTime changes, not other state', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
// Change refreshInterval (not selectedTime)
|
||||
act(() => {
|
||||
result.current.setRefreshInterval(5000);
|
||||
});
|
||||
|
||||
// Wait to ensure subscription handler had a chance to run
|
||||
await waitFor(() => {
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
// Should NOT have written to localStorage for refreshInterval change
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
|
||||
// Now change selectedTime
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to initialTime when localStorage contains empty string', () => {
|
||||
localStorage.setItem('test-key', '');
|
||||
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Empty string is falsy, should use initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should write custom time range to localStorage', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-custom-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshInterval', () => {
|
||||
it('should initialize with provided refreshInterval', () => {
|
||||
const wrapper = createWrapper({ refreshInterval: 5000 });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
} from '../globalTimeStore';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('createGlobalTimeStore', () => {
|
||||
describe('factory function', () => {
|
||||
it('should create independent store instances', () => {
|
||||
const store1 = createGlobalTimeStore();
|
||||
const store2 = createGlobalTimeStore();
|
||||
|
||||
store1.getState().setSelectedTime('1h');
|
||||
|
||||
expect(store1.getState().selectedTime).toBe('1h');
|
||||
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should accept initial state', () => {
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().selectedTime).toBe('15m');
|
||||
expect(store.getState().refreshInterval).toBe(5000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute isRefreshEnabled correctly for custom time', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultGlobalTimeStore', () => {
|
||||
it('should be a singleton', () => {
|
||||
expect(defaultGlobalTimeStore).toBeDefined();
|
||||
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshInterval', () => {
|
||||
it('should update refresh interval and enable refresh', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh when interval is 0', () => {
|
||||
const store = createGlobalTimeStore({ refreshInterval: 5000 });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(0);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(0);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should not enable refresh for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store name', () => {
|
||||
it('should store name when provided', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
expect(store.getState().name).toBe('drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
expect(store.getState().name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should generate key without name for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate key with name for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'drawer',
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'test' });
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'test',
|
||||
'1h',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeSelectedTime } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should have isRefreshEnabled as false by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have refreshInterval as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime', () => {
|
||||
it('should update selectedTime', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update refreshInterval when provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should keep existing refreshInterval when not provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should enable refresh for relative time with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh for relative time with refreshInterval = 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime, 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle various relative time formats', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const timeFormats: GlobalTimeSelectedTime[] = [
|
||||
'1m',
|
||||
'5m',
|
||||
'15m',
|
||||
'30m',
|
||||
'1h',
|
||||
'3h',
|
||||
'6h',
|
||||
'1d',
|
||||
'1w',
|
||||
];
|
||||
|
||||
timeFormats.forEach((time) => {
|
||||
act(() => {
|
||||
result.current.setSelectedTime(time, 10000);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe(time);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinMaxTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return min/max time for custom time range', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime: resultMin, maxTime: resultMax } =
|
||||
result.current.getMinMaxTime();
|
||||
expect(resultMin).toBe(minTime);
|
||||
expect(resultMax).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should compute fresh min/max time for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
const now = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 1 second
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// maxTime should be different (1 second later)
|
||||
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
|
||||
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should share state between multiple hook instances', () => {
|
||||
const { result: result1 } = renderHook(() => useGlobalTimeStore());
|
||||
const { result: result2 } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result1.current.setSelectedTime('1h', 10000);
|
||||
});
|
||||
|
||||
expect(result2.current.selectedTime).toBe('1h');
|
||||
expect(result2.current.refreshInterval).toBe(10000);
|
||||
expect(result2.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
/**
|
||||
* Creates an isolated store wrapper for testing.
|
||||
* Each test gets its own store instance, avoiding test pollution.
|
||||
*/
|
||||
function createIsolatedWrapper(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
const store = createGlobalTimeStore(initialState);
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should have isRefreshEnabled as false by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have refreshInterval as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
});
|
||||
|
||||
it('should have lastRefreshTimestamp as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
});
|
||||
|
||||
it('should have lastComputedMinMax with default values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime', () => {
|
||||
it('should update selectedTime', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update refreshInterval when provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should keep existing refreshInterval when not provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should enable refresh for relative time with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh for relative time with refreshInterval = 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime, 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle various relative time formats', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const timeFormats: GlobalTimeSelectedTime[] = [
|
||||
'1m',
|
||||
'5m',
|
||||
'15m',
|
||||
'30m',
|
||||
'1h',
|
||||
'3h',
|
||||
'6h',
|
||||
'1d',
|
||||
'1w',
|
||||
];
|
||||
|
||||
timeFormats.forEach((time) => {
|
||||
act(() => {
|
||||
result.current.setSelectedTime(time, 10000);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe(time);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute and store lastComputedMinMax when selectedTime changes', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// setSelectedTime computes values on init (createIsolatedWrapper uses createGlobalTimeStore)
|
||||
// But initial store state has minTime/maxTime as 0 until first setSelectedTime is called
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Now switch to a custom time range
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// lastComputedMinMax should be updated to the custom range values
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 1000000000,
|
||||
maxTime: 2000000000,
|
||||
});
|
||||
expect(result.current.lastComputedMinMax).not.toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should return fresh custom time values after switching from relative time', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Compute and cache values for relative time
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const relativeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Switch to custom time range
|
||||
const customMinTime = 5000000000;
|
||||
const customMaxTime = 6000000000;
|
||||
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return the custom time values, not cached relative values
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned.minTime).toBe(customMinTime);
|
||||
expect(returned.maxTime).toBe(customMaxTime);
|
||||
expect(returned).not.toStrictEqual(relativeMinMax);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinMaxTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return min/max time for custom time range', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime: resultMin, maxTime: resultMax } =
|
||||
result.current.getMinMaxTime();
|
||||
expect(resultMin).toBe(minTime);
|
||||
expect(resultMax).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(minTime).toBe(minTimeWithSeconds);
|
||||
expect(maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(minTime).not.toBe(minTimeRounded);
|
||||
expect(maxTime).not.toBe(maxTimeRounded);
|
||||
});
|
||||
|
||||
it('should compute fresh min/max time for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
const now = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return same values on subsequent calls when refresh disabled (under minute boundary)', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(59000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// With refresh disabled, should return cached lastComputedMinMax
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
expect(second.minTime).toBe(first.minTime);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls when refresh disabled after minute boundary', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Without refresh enabled, getMinMaxTime returns cached values
|
||||
// Need to call computeAndStoreMinMax to get new values
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
|
||||
// After computing, values should update
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const third = result.current.getMinMaxTime();
|
||||
expect(third.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
expect(third.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
|
||||
it('should return stored lastComputedMinMax when available', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const stored = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time by 5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return stored values, not fresh computation
|
||||
const returned = result.current.getMinMaxTime();
|
||||
expect(returned).toStrictEqual(stored);
|
||||
});
|
||||
|
||||
describe('with isRefreshEnabled (isolated store)', () => {
|
||||
it('should compute fresh values when isRefreshEnabled is true (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// getMinMaxTime computes 5s-rounded values when refresh enabled
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return fresh values, not cached
|
||||
const freshValues = result.current.getMinMaxTime();
|
||||
|
||||
expect(freshValues.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(freshValues.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastComputedMinMax when values change (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (uses 5s rounding when refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update lastComputedMinMax
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when values change', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update timestamp
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT update lastComputedMinMax when values have not changed (same 5s window)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (triggers computation for 5s-rounded values)
|
||||
result.current.getMinMaxTime();
|
||||
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time but stay within same 5-second window
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should NOT update store (same 5s boundary)
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
// Values should be unchanged (no unnecessary re-renders)
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should return cached values when isRefreshEnabled is false', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Refresh disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const storedMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return cached values since refresh is disabled
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned).toStrictEqual(storedMinMax);
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
|
||||
});
|
||||
|
||||
it('should return same values for custom time range regardless of time passing', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// isRefreshEnabled should be false for custom time ranges
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
|
||||
// Custom time ranges always return the fixed values, not relative to "now"
|
||||
const first = result.current.getMinMaxTime();
|
||||
expect(first.minTime).toBe(minTime);
|
||||
expect(first.maxTime).toBe(maxTime);
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Should still return the same fixed values (custom range doesn't drift)
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.minTime).toBe(minTime);
|
||||
expect(second.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive refetch intervals correctly (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Simulate 3 refetch intervals crossing 5-second boundaries
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + i * 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// maxTime should be the current time (no rounding when refresh disabled)
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const beforeTimestamp = Date.now();
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
|
||||
beforeTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the computed values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
|
||||
});
|
||||
|
||||
it('should NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
|
||||
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
|
||||
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
|
||||
|
||||
// lastComputedMinMax should also have exact values
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRefreshTimestamp', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp to current time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should not modify lastComputedMinMax', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const beforeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should share state between multiple hook instances', () => {
|
||||
const { result: result1 } = renderHook(() => useGlobalTimeStore());
|
||||
const { result: result2 } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result1.current.setSelectedTime('1h', 10000);
|
||||
});
|
||||
|
||||
expect(result2.current.selectedTime).toBe('1h');
|
||||
expect(result2.current.refreshInterval).toBe(10000);
|
||||
expect(result2.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime (min/max computation)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max for relative time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Initial state has 0 values
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
// Should have computed values immediately
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:00:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should compute and store min/max for custom time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTime);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should skip update when same selectedTime and refreshInterval', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// Try to set same values again
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
// Should not have updated timestamp (no state change)
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax (refresh behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should skip computation and return lastComputedMinMax when refresh is enabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values via getMinMaxTime (which computes for refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should skip computation when refresh is enabled
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return the current lastComputedMinMax, not fresh computation
|
||||
expect(returnedValue).toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should compute fresh values when refresh is disabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should compute fresh values
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return new values
|
||||
expect(returnedValue?.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { createGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from '../hooks';
|
||||
import { useComputedMinMaxSync } from '../useComputedMinMaxSync';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('useGlobalTime', () => {
|
||||
it('should return full store state without selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime());
|
||||
|
||||
expect(result.current.selectedTime).toBeDefined();
|
||||
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should return selected value with selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
|
||||
|
||||
expect(typeof result.current).toBe('string');
|
||||
});
|
||||
|
||||
it('should use context store when provided', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsCustomTimeRange', () => {
|
||||
it('should return false for relative time', () => {
|
||||
const { result } = renderHook(() => useIsCustomTimeRange());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
const { result } = renderHook(() => useIsCustomTimeRange(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlobalTimeStoreApi', () => {
|
||||
it('should return store API', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStoreApi());
|
||||
|
||||
expect(result.current.getState).toBeInstanceOf(Function);
|
||||
expect(result.current.subscribe).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastComputedMinMax', () => {
|
||||
it('should return lastComputedMinMax from store', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
// Compute the min/max first
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toStrictEqual(
|
||||
contextStore.getState().lastComputedMinMax,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update when store changes', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
const firstValue = { ...result.current };
|
||||
|
||||
// Change time and recompute
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000); // Advance 1 minute
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current).not.toStrictEqual(firstValue);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useComputedMinMaxSync', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute min/max on mount when store has zero values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should have computed values now
|
||||
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
|
||||
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should NOT recompute when store already has values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
const initialMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const initialTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should NOT have recomputed - values should be unchanged
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
initialMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should only compute on mount, not on re-renders', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
const { rerender } = renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
const afterMountMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const afterMountTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
rerender();
|
||||
|
||||
// Should NOT have recomputed on re-render
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
afterMountMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(
|
||||
afterMountTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useGlobalTimeQueryInvalidate', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return a function', () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(typeof result.current).toBe('function');
|
||||
});
|
||||
|
||||
it('should call computeAndStoreMinMax before invalidating queries (refresh disabled)', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ initialTime: '15m', refreshInterval: 0 }, // refresh disabled so computeAndStoreMinMax computes fresh values
|
||||
queryClient,
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Initial computation - need to call computeAndStoreMinMax first
|
||||
act(() => {
|
||||
result.current.globalTime.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call invalidate - should compute fresh values when refresh is disabled
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// lastComputedMinMax should have been updated
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up a query with AUTO_REFRESH_QUERY key
|
||||
const { result: queryResult } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: mockQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial query to complete
|
||||
await waitFor(() => {
|
||||
expect(queryResult.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Now render the invalidate hook and call it
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Query should have been refetched
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
|
||||
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
|
||||
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up both types of queries
|
||||
const { result: autoRefreshQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
|
||||
queryFn: autoRefreshQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result: regularQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: regularQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries to complete
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQuery.current.isSuccess).toBe(true);
|
||||
expect(regularQuery.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Call invalidate
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Only auto-refresh query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Regular query should NOT be refetched
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use exact custom time values (not rounded) when invalidating', async () => {
|
||||
// Use timestamps that are NOT on minute boundaries
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Call invalidate
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// Verify custom time values are NOT rounded
|
||||
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
|
||||
minTimeWithSeconds,
|
||||
);
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
|
||||
queryFn: queryFn3,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// All queries should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped invalidation with store name', () => {
|
||||
it('should only invalidate queries matching store name', async () => {
|
||||
const namedQueryFn = jest.fn().mockResolvedValue({ data: 'named' });
|
||||
const unnamedQueryFn = jest.fn().mockResolvedValue({ data: 'unnamed' });
|
||||
|
||||
const wrapper = createWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'named-query'],
|
||||
queryFn: namedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Query without name (different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'unnamed-query'],
|
||||
queryFn: unnamedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Only named query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Unnamed query should NOT be refetched
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should invalidate all queries for unnamed store (backward compatible)', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
|
||||
// Unnamed store (no name prop)
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Both should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createProviderWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useIsGlobalTimeQueryRefreshing', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return false when no queries are fetching', () => {
|
||||
const wrapper = createWrapper(queryClient);
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start the auto-refresh query
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false after fetching completes
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start a regular query (not auto-refresh)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - not an auto-refresh query
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
|
||||
let resolveQuery1: (value: unknown) => void;
|
||||
let resolveQuery2: (value: unknown) => void;
|
||||
const queryPromise1 = new Promise((resolve) => {
|
||||
resolveQuery1 = resolve;
|
||||
});
|
||||
const queryPromise2 = new Promise((resolve) => {
|
||||
resolveQuery2 = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: () => queryPromise1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: () => queryPromise2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve first query
|
||||
act(() => {
|
||||
resolveQuery1({ data: 'done1' });
|
||||
});
|
||||
|
||||
// Should still be true (second query still fetching)
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
// Resolve second query
|
||||
act(() => {
|
||||
resolveQuery2({ data: 'done2' });
|
||||
});
|
||||
|
||||
// Should be false after all complete
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
|
||||
let resolveAutoRefresh: (value: unknown) => void;
|
||||
let resolveRegular: (value: unknown) => void;
|
||||
const autoRefreshPromise = new Promise((resolve) => {
|
||||
resolveAutoRefresh = resolve;
|
||||
});
|
||||
const regularPromise = new Promise((resolve) => {
|
||||
resolveRegular = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start both types of queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
|
||||
queryFn: () => autoRefreshPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular'],
|
||||
queryFn: () => regularPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true (auto-refresh is fetching)
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve auto-refresh query
|
||||
act(() => {
|
||||
resolveAutoRefresh({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false even though regular query is still fetching
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveRegular({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped refreshing check with store name', () => {
|
||||
it('should return true only for queries matching store name', async () => {
|
||||
let resolveNamedQuery: (value: unknown) => void;
|
||||
const namedQueryPromise = new Promise((resolve) => {
|
||||
resolveNamedQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => namedQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true - named query is fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveNamedQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when only different store queries are fetching', async () => {
|
||||
let resolveOtherQuery: (value: unknown) => void;
|
||||
const otherQueryPromise = new Promise((resolve) => {
|
||||
resolveOtherQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with different name (belongs to different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => otherQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status for 'drawer' store
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - the fetching query belongs to 'other-store', not 'drawer'
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveOtherQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
|
||||
import { useQueryCacheSync } from '../useQueryCacheSync';
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper(
|
||||
queryClient: QueryClient,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useQueryCacheSync', () => {
|
||||
let store: GlobalTimeStoreApi;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createGlobalTimeStore();
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
|
||||
// Initialize store
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Render the hook
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a successful auto-refresh query
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update timestamp for non-auto-refresh queries', async () => {
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a regular query (not auto-refresh)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: ['some-other-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
describe('store name filtering', () => {
|
||||
it('should update timestamp for named store when matching query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with matching name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when different name query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with different name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when unnamed query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query without name (unnamed store format)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
roundDownTo5Seconds,
|
||||
} from '../utils';
|
||||
|
||||
describe('globalTime/utils', () => {
|
||||
@@ -136,4 +141,184 @@ describe('globalTime/utils', () => {
|
||||
expect(result.minTime).toBe(now - oneDayNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundDownTo5Seconds', () => {
|
||||
it('should round down timestamp to 5-second boundary', () => {
|
||||
// 12:30:47.123Z -> 12:30:45.000Z
|
||||
const inputNano = 1705321847123 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321845000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should not change timestamp already at 5-second boundary', () => {
|
||||
const inputNano = 1705321845000 * NANO_SECOND_MULTIPLIER; // 12:30:45.000
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:04.999 down to 12:30:00.000', () => {
|
||||
const inputNano = 1705321804999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:09.999 down to 12:30:05.000', () => {
|
||||
const inputNano = 1705321809999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321805000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should handle timestamp at exact 5-second intervals', () => {
|
||||
// Test 5, 10, 15, 20, 25... second marks
|
||||
const base = 1705321800000; // 12:30:00
|
||||
for (let sec = 0; sec < 60; sec += 5) {
|
||||
const inputNano = (base + sec * 1000) * NANO_SECOND_MULTIPLIER;
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRounded5sMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:47.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return maxTime rounded to 5-second boundary for relative time', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
// maxTime should be rounded down to 12:30:45.000
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
expect(result.maxTime).toBe(expectedMaxTime);
|
||||
});
|
||||
|
||||
it('should compute minTime based on 5s-rounded maxTime', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return unchanged values for custom time range', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const result = computeRounded5sMinMax(customTime);
|
||||
|
||||
expect(result.minTime).toBe(minTime);
|
||||
expect(result.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should preserve duration for 1h relative time', () => {
|
||||
const result = computeRounded5sMinMax('1h');
|
||||
|
||||
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
const duration = result.maxTime - result.minTime;
|
||||
|
||||
expect(duration).toBe(oneHourNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
|
||||
|
||||
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
|
||||
});
|
||||
|
||||
it('should append selectedTime at end', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no additional query parts', () => {
|
||||
const result = getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
|
||||
it('should handle custom time range as selectedTime', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'METRICS',
|
||||
customTime,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle object query parts', () => {
|
||||
const params = { entityId: '123', filter: 'active' };
|
||||
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'ENTITY',
|
||||
params,
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey deprecation', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
it('should log deprecation warning in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deprecated'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT log deprecation warning in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still return correct query key format', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,144 @@
|
||||
import { createStore, StoreApi, useStore } from 'zustand';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
import { isCustomTimeRange, parseSelectedTime } from './utils';
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
isCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
|
||||
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
|
||||
selectedTime: DEFAULT_TIME_RANGE,
|
||||
isRefreshEnabled: false,
|
||||
refreshInterval: 0,
|
||||
setSelectedTime: (selectedTime, refreshInterval): void => {
|
||||
set((state) => {
|
||||
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
|
||||
const isCustom = isCustomTimeRange(selectedTime);
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
): boolean {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return false;
|
||||
}
|
||||
return refreshInterval > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTime,
|
||||
refreshInterval: newRefreshInterval,
|
||||
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
getMinMaxTime: (selectedTime): ParsedTimeRange => {
|
||||
return parseSelectedTime(selectedTime || get().selectedTime);
|
||||
},
|
||||
}));
|
||||
export function createGlobalTimeStore(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): GlobalTimeStoreApi {
|
||||
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = initialState?.refreshInterval ?? 0;
|
||||
const name = initialState?.name;
|
||||
|
||||
return createStore<GlobalTimeStore>((set, get) => ({
|
||||
name,
|
||||
selectedTime,
|
||||
refreshInterval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
|
||||
lastRefreshTimestamp: 0,
|
||||
lastComputedMinMax: { minTime: 0, maxTime: 0 },
|
||||
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
newRefreshInterval?: number,
|
||||
): void => {
|
||||
const state = get();
|
||||
const interval = newRefreshInterval ?? state.refreshInterval;
|
||||
|
||||
if (time === state.selectedTime && interval === state.refreshInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(time);
|
||||
|
||||
set({
|
||||
selectedTime: time,
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
setRefreshInterval: (interval: number): void => {
|
||||
set((state) => ({
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
|
||||
}));
|
||||
},
|
||||
|
||||
getMinMaxTime: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
return parseSelectedTime(state.selectedTime);
|
||||
}
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
const freshMinMax = computeRounded5sMinMax(state.selectedTime);
|
||||
|
||||
if (
|
||||
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
|
||||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
|
||||
) {
|
||||
set({ lastComputedMinMax: freshMinMax, lastRefreshTimestamp: Date.now() });
|
||||
}
|
||||
|
||||
return freshMinMax;
|
||||
}
|
||||
|
||||
return state.lastComputedMinMax;
|
||||
},
|
||||
|
||||
computeAndStoreMinMax: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
return state.lastComputedMinMax;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(state.selectedTime);
|
||||
|
||||
set({
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
return computedMinMax;
|
||||
},
|
||||
|
||||
updateRefreshTimestamp: (): void => {
|
||||
set({ lastRefreshTimestamp: Date.now() });
|
||||
},
|
||||
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] => {
|
||||
const storeName = get().name;
|
||||
if (storeName) {
|
||||
return [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
storeName,
|
||||
...queryParts,
|
||||
selectedTime,
|
||||
];
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const defaultGlobalTimeStore = createGlobalTimeStore();
|
||||
|
||||
export const useGlobalTimeStore = <T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
): T => {
|
||||
return useStore(
|
||||
defaultGlobalTimeStore,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
);
|
||||
};
|
||||
|
||||
57
frontend/src/store/globalTime/hooks.ts
Normal file
57
frontend/src/store/globalTime/hooks.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { GlobalTimeContext } from './GlobalTimeContext';
|
||||
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeStore, ParsedTimeRange } from './types';
|
||||
import { isCustomTimeRange } from './utils';
|
||||
|
||||
/**
|
||||
* Access global time state with optional selector for performance.
|
||||
*
|
||||
* @example
|
||||
* // Full state (re-renders on any change)
|
||||
* const { selectedTime, setSelectedTime } = useGlobalTime();
|
||||
*
|
||||
* @example
|
||||
* // With selector (re-renders only when selectedTime changes)
|
||||
* const selectedTime = useGlobalTime(state => state.selectedTime);
|
||||
*/
|
||||
export function useGlobalTime<T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
const store = contextStore ?? defaultGlobalTimeStore;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
store,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
equalityFn,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently using a custom time range.
|
||||
*/
|
||||
export function useIsCustomTimeRange(): boolean {
|
||||
const selectedTime = useGlobalTime((state) => state.selectedTime);
|
||||
return isCustomTimeRange(selectedTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the store API directly (for subscriptions or non-React contexts).
|
||||
*/
|
||||
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
return contextStore ?? defaultGlobalTimeStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last computed min/max time values.
|
||||
* Use this for display purposes to ensure consistency with query data.
|
||||
*/
|
||||
export function useLastComputedMinMax(): ParsedTimeRange {
|
||||
return useGlobalTime((state) => state.lastComputedMinMax);
|
||||
}
|
||||
@@ -1,9 +1,558 @@
|
||||
export { useGlobalTimeStore } from './globalTimeStore';
|
||||
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
|
||||
/**
|
||||
* # Global Time Store
|
||||
*
|
||||
* Centralized time management for the application with auto-refresh support.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ```tsx
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* const { data } = useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return fetchData({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Core Concepts
|
||||
*
|
||||
* ### Time Formats
|
||||
*
|
||||
* | Format | Example | Description |
|
||||
* |--------|---------|-------------|
|
||||
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
|
||||
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
|
||||
*
|
||||
* ### Time Units
|
||||
*
|
||||
* - Store values are in **nanoseconds**
|
||||
* - Most APIs expect **seconds**
|
||||
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
|
||||
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
|
||||
*
|
||||
* ## Integration Guide
|
||||
*
|
||||
* ### Step 1: Get Store State
|
||||
*
|
||||
* Use selectors for optimal re-render performance:
|
||||
*
|
||||
* ```tsx
|
||||
* // Good - only re-renders when selectedTime changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
*
|
||||
* // Avoid - re-renders on ANY store change
|
||||
* const store = useGlobalTime();
|
||||
* ```
|
||||
*
|
||||
* ### Step 2: Build Query Key
|
||||
*
|
||||
* Use the store's `getAutoRefreshQueryKey` to enable auto-refresh:
|
||||
*
|
||||
* ```tsx
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(
|
||||
* selectedTime, // Required - triggers invalidation
|
||||
* 'UNIQUE_KEY', // Your query identifier
|
||||
* ...otherParams // Additional cache-busting params
|
||||
* ),
|
||||
* [getAutoRefreshQueryKey, selectedTime, ...deps]
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* **Note:** For named providers (with `name` prop), query keys are automatically
|
||||
* scoped to that store, enabling isolated invalidation and refresh tracking.
|
||||
*
|
||||
* ### Step 3: Fetch Data
|
||||
*
|
||||
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
|
||||
*
|
||||
* ```tsx
|
||||
* const { data } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Fresh time values computed here during auto-refresh
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return api.fetch({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Step 4: Add Refresh Button (Optional)
|
||||
*
|
||||
* ```tsx
|
||||
* import {
|
||||
* useGlobalTimeQueryInvalidate,
|
||||
* useIsGlobalTimeQueryRefreshing,
|
||||
* } from 'store/globalTime';
|
||||
*
|
||||
* function RefreshButton() {
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
|
||||
*
|
||||
* return (
|
||||
* <button onClick={invalidate} disabled={isRefreshing}>
|
||||
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Avoiding Stale Data
|
||||
*
|
||||
* ### Problem: Time Drift During Refresh
|
||||
*
|
||||
* If multiple queries compute time independently, they may use different values:
|
||||
*
|
||||
* ```tsx
|
||||
* // BAD - each query gets different time
|
||||
* queryFn: () => {
|
||||
* const now = Date.now();
|
||||
* return fetchData({ end: now, start: now - duration });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Solution: Use getMinMaxTime()
|
||||
*
|
||||
* `getMinMaxTime()` ensures all queries use consistent timestamps:
|
||||
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
|
||||
* - When auto-refresh is **enabled**: computes fresh values (rounded to 5-second boundaries)
|
||||
*
|
||||
* Since values are rounded to 5-second boundaries, all queries calling `getMinMaxTime()`
|
||||
* within the same 5-second window get identical timestamps.
|
||||
*
|
||||
* ```tsx
|
||||
* // GOOD - all queries get same time
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* return fetchData({ start: minTime, end: maxTime });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### How It Works
|
||||
*
|
||||
* **Manual refresh:**
|
||||
* 1. User clicks refresh
|
||||
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
|
||||
* 3. Fresh min/max stored in `lastComputedMinMax`
|
||||
* 4. All queries re-run and call `getMinMaxTime()`
|
||||
* 5. All get the SAME cached values
|
||||
*
|
||||
* **Auto-refresh (when `isRefreshEnabled = true`):**
|
||||
* 1. React-query's `refetchInterval` triggers query re-execution
|
||||
* 2. `getMinMaxTime()` computes fresh values (rounded to 5 seconds)
|
||||
* 3. If values changed, updates `lastComputedMinMax` cache
|
||||
* 4. All queries within same 5-second window get consistent values
|
||||
*
|
||||
* ## Auto-Refresh Setup
|
||||
*
|
||||
* Auto-refresh is enabled when:
|
||||
* - `selectedTime` is a relative duration (e.g., `'15m'`)
|
||||
* - `refreshInterval > 0`
|
||||
*
|
||||
* ```tsx
|
||||
* // Auto-refresh configuration
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
|
||||
* queryFn: () => { ... },
|
||||
* // Enable periodic refetch
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ## API Reference
|
||||
*
|
||||
* ### Hooks
|
||||
*
|
||||
* | Hook | Returns | Description |
|
||||
* |------|---------|-------------|
|
||||
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
|
||||
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
|
||||
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
|
||||
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
|
||||
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
|
||||
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
|
||||
*
|
||||
* ### Store Actions
|
||||
*
|
||||
* | Action | Description |
|
||||
* |--------|-------------|
|
||||
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
|
||||
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
|
||||
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
|
||||
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
|
||||
*
|
||||
* ### Utilities
|
||||
*
|
||||
* | Function | Description |
|
||||
* |----------|-------------|
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | **@deprecated** Use store action instead |
|
||||
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
|
||||
* | `isCustomTimeRange(time)` | Check if time is custom range format |
|
||||
* | `createCustomTimeRange(min, max)` | Create custom range string |
|
||||
*
|
||||
* ### Constants
|
||||
*
|
||||
* | Constant | Value | Description |
|
||||
* |----------|-------|-------------|
|
||||
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
|
||||
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
|
||||
*
|
||||
* ## Context & Composition
|
||||
*
|
||||
* ### Why Use Context?
|
||||
*
|
||||
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
|
||||
* to create isolated time state for specific UI sections (modals, drawers, etc.).
|
||||
*
|
||||
* ### Provider Options
|
||||
*
|
||||
* | Option | Type | Description |
|
||||
* |--------|------|-------------|
|
||||
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
|
||||
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
|
||||
* | `initialTime` | `string` | Initial time if not inheriting |
|
||||
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
|
||||
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
|
||||
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
|
||||
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
|
||||
*
|
||||
* ### Example 1: Isolated Time in Modal
|
||||
*
|
||||
* A modal with its own time picker that doesn't affect the main page:
|
||||
*
|
||||
* ```tsx
|
||||
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
|
||||
*
|
||||
* function EntityDetailsModal({ entity, onClose }) {
|
||||
* return (
|
||||
* <Modal open onClose={onClose}>
|
||||
* // Isolated time context - changes here don't affect parent
|
||||
* <GlobalTimeProvider
|
||||
* inheritGlobalTime // Start with parent's current time
|
||||
* refreshInterval={0} // No auto-refresh in modal
|
||||
* >
|
||||
* <ModalContent entity={entity} />
|
||||
* </GlobalTimeProvider>
|
||||
* </Modal>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ModalContent({ entity }) {
|
||||
* // This useGlobalTime reads from the modal's isolated store
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <DateTimePicker
|
||||
* value={selectedTime}
|
||||
* onChange={(time) => setSelectedTime(time)}
|
||||
* />
|
||||
* <EntityMetrics entity={entity} />
|
||||
* <EntityLogs entity={entity} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 2: List Page with Detail Drawer
|
||||
*
|
||||
* Main list uses global time, drawer has independent time:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main list page - uses global time (no provider needed)
|
||||
* function K8sPodsList() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const [selectedPod, setSelectedPod] = useState(null);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <PageHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls global time
|
||||
* </PageHeader>
|
||||
*
|
||||
* <PodsTable
|
||||
* timeRange={selectedTime}
|
||||
* onRowClick={setSelectedPod}
|
||||
* />
|
||||
*
|
||||
* {selectedPod && (
|
||||
* <PodDetailsDrawer
|
||||
* pod={selectedPod}
|
||||
* onClose={() => setSelectedPod(null)}
|
||||
* />
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Drawer with its own time context
|
||||
* function PodDetailsDrawer({ pod, onClose }) {
|
||||
* return (
|
||||
* <Drawer open onClose={onClose}>
|
||||
* <GlobalTimeProvider
|
||||
* name="pod-drawer" // Scopes queries - only this drawer's queries are invalidated
|
||||
* inheritGlobalTime // Start with list's time
|
||||
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'drawerTime',
|
||||
* startTimeKey: 'drawerStart',
|
||||
* endTimeKey: 'drawerEnd',
|
||||
* }}
|
||||
* >
|
||||
* <DrawerHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls drawer's time only
|
||||
* </DrawerHeader>
|
||||
*
|
||||
* <Tabs>
|
||||
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
|
||||
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
|
||||
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
|
||||
* </Tabs>
|
||||
* </GlobalTimeProvider>
|
||||
* </Drawer>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 3: Nested Contexts
|
||||
*
|
||||
* Contexts can be nested - each level creates isolation:
|
||||
*
|
||||
* ```tsx
|
||||
* // App level - global time
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryClientProvider>
|
||||
* // No provider here = uses defaultGlobalTimeStore
|
||||
* <Dashboard />
|
||||
* </QueryClientProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Dashboard with comparison panel
|
||||
* function Dashboard() {
|
||||
* return (
|
||||
* <div className="dashboard">
|
||||
* // Main dashboard uses global time
|
||||
* <MainCharts />
|
||||
*
|
||||
* // Comparison panel has its own time
|
||||
* <GlobalTimeProvider initialTime="1h">
|
||||
* <ComparisonPanel />
|
||||
* </GlobalTimeProvider>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ComparisonPanel() {
|
||||
* // This reads from ComparisonPanel's isolated store (1h)
|
||||
* // Not affected by global time changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* return <ComparisonCharts timeRange={selectedTime} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 4: URL Sync for Shareable Links
|
||||
*
|
||||
* Persist time selection to URL for shareable links:
|
||||
*
|
||||
* ```tsx
|
||||
* function TracesExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'time', // ?time=15m
|
||||
* startTimeKey: 'startTime', // ?startTime=1234567890
|
||||
* endTimeKey: 'endTime', // ?endTime=1234567899
|
||||
* }}
|
||||
* initialTime="15m" // Fallback if URL has no time params
|
||||
* >
|
||||
* <TracesContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 5: localStorage Persistence
|
||||
*
|
||||
* Remember user's last selected time across sessions:
|
||||
*
|
||||
* ```tsx
|
||||
* function MetricsExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* localStoragePersistKey="metrics-explorer-time"
|
||||
* initialTime="1h" // Fallback for first visit
|
||||
* >
|
||||
* <MetricsContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Context Resolution Order
|
||||
*
|
||||
* When `useGlobalTime()` is called, it resolves the store in this order:
|
||||
*
|
||||
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
|
||||
* 2. `defaultGlobalTimeStore` (global singleton)
|
||||
*
|
||||
* ```
|
||||
* App (no provider -> uses defaultGlobalTimeStore)
|
||||
* |-- Dashboard
|
||||
* |-- MainCharts (uses defaultGlobalTimeStore)
|
||||
* |-- GlobalTimeProvider (isolated store A)
|
||||
* |-- ComparisonPanel (uses store A)
|
||||
* |-- GlobalTimeProvider (isolated store B)
|
||||
* |-- NestedChart (uses store B)
|
||||
* ```
|
||||
*
|
||||
* ### Scoped Query Keys with `name`
|
||||
*
|
||||
* The `name` prop enables isolated query invalidation. When a provider has a name,
|
||||
* its queries are prefixed with that name, so invalidation only affects that store:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main page - unnamed store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'METRICS', ...]
|
||||
* function MainDashboard() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* // Drawer - named store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'drawer', 'METRICS', ...]
|
||||
* function DetailDrawer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider name="drawer" inheritGlobalTime>
|
||||
* <DrawerContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function DrawerContent() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* // invalidate() only refreshes queries with 'drawer' prefix
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Complete Example
|
||||
*
|
||||
* ```tsx
|
||||
* import { useMemo } from 'react';
|
||||
* import { useQuery } from 'react-query';
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MetricsPanel({ entityId }: { entityId: string }) {
|
||||
* // 1. Get store state with selectors
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* // 2. Build query key (memoized) - automatically scoped if using named provider
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
|
||||
* [getAutoRefreshQueryKey, selectedTime, entityId]
|
||||
* );
|
||||
*
|
||||
* // 3. Query with auto-refresh
|
||||
* const { data, isLoading } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Get fresh time inside queryFn
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
*
|
||||
* return fetchMetrics({ entityId, start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
*
|
||||
* return <Chart data={data} loading={isLoading} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @module store/globalTime
|
||||
*/
|
||||
|
||||
// Store
|
||||
export {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
useGlobalTimeStore,
|
||||
} from './globalTimeStore';
|
||||
export type { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
// Context & Provider
|
||||
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from './hooks';
|
||||
|
||||
// Query hooks for auto-refresh
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
CustomTimeRange,
|
||||
CustomTimeRangeSeparator,
|
||||
GlobalTimeActions,
|
||||
GlobalTimeProviderOptions,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
// Internal hooks (for advanced use cases)
|
||||
export { useQueryCacheSync } from './useQueryCacheSync';
|
||||
|
||||
@@ -44,9 +44,80 @@ export interface IGlobalTimeStoreActions {
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Get the current min/max time values parsed from selectedTime.
|
||||
* For durations, computes fresh values based on Date.now().
|
||||
* For custom ranges, extracts the stored values.
|
||||
* Get the current min/max time values.
|
||||
* - Custom time ranges: returns exact parsed values
|
||||
* - isRefreshEnabled true: computes 5s-rounded values and updates store
|
||||
* - isRefreshEnabled false: returns lastComputedMinMax
|
||||
*/
|
||||
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeProviderOptions {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys - only queries with this store's prefix
|
||||
* will be tracked/invalidated by this store's hooks.
|
||||
*/
|
||||
name?: string;
|
||||
/** Initialize from parent/global time */
|
||||
inheritGlobalTime?: boolean;
|
||||
/** Initial time if not inheriting */
|
||||
initialTime?: GlobalTimeSelectedTime;
|
||||
/** URL sync configuration. When false/omitted, no URL sync. */
|
||||
enableUrlParams?:
|
||||
| boolean
|
||||
| {
|
||||
relativeTimeKey?: string;
|
||||
startTimeKey?: string;
|
||||
endTimeKey?: string;
|
||||
};
|
||||
removeQueryParamsOnUnmount?: boolean;
|
||||
localStoragePersistKey?: string;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export interface GlobalTimeState {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys for auto-refresh queries.
|
||||
* Unnamed stores use the default prefix without a name.
|
||||
*/
|
||||
name?: string;
|
||||
selectedTime: GlobalTimeSelectedTime;
|
||||
refreshInterval: number;
|
||||
isRefreshEnabled: boolean;
|
||||
lastRefreshTimestamp: number;
|
||||
lastComputedMinMax: ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeActions {
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
refreshInterval?: number,
|
||||
) => void;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
/**
|
||||
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
|
||||
* Call this before invalidating queries to ensure all queries use the same time values.
|
||||
*
|
||||
* @returns The newly computed ParsedTimeRange
|
||||
*/
|
||||
computeAndStoreMinMax: () => ParsedTimeRange;
|
||||
/**
|
||||
* Update the refresh timestamp to current time.
|
||||
* Called by QueryCache listener when auto-refresh queries complete.
|
||||
*/
|
||||
updateRefreshTimestamp: () => void;
|
||||
/**
|
||||
* Build query key for auto-refresh queries scoped to this store.
|
||||
* Named stores: ['AUTO_REFRESH_QUERY', name, ...parts, selectedTime]
|
||||
* Unnamed stores: ['AUTO_REFRESH_QUERY', ...parts, selectedTime]
|
||||
*/
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
) => unknown[];
|
||||
}
|
||||
|
||||
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;
|
||||
|
||||
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to initialize computed min/max on mount when store has no values yet.
|
||||
* setSelectedTime now computes min/max on change, so subscription is no longer needed.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
|
||||
useEffect(() => {
|
||||
const { lastComputedMinMax } = store.getState();
|
||||
if (lastComputedMinMax.minTime === 0 && lastComputedMinMax.maxTime === 0) {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
}
|
||||
}, [store]);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*
|
||||
* This hook computes fresh time values before invalidating queries,
|
||||
* ensuring all queries use the same min/max time during a refresh cycle.
|
||||
*
|
||||
* For named stores, only invalidates queries matching the store's name.
|
||||
* For unnamed stores, invalidates all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Compute fresh time values BEFORE invalidating
|
||||
// This ensures all queries that re-run will use the same time values
|
||||
// If refresh is enabled, this will just be skipped
|
||||
computeAndStoreMinMax();
|
||||
|
||||
// Build scoped query key prefix
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return await queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, computeAndStoreMinMax, name]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*
|
||||
* For named stores, only checks queries matching the store's name.
|
||||
* For unnamed stores, checks all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return useIsFetching({ queryKey }) > 0;
|
||||
}
|
||||
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep the selected time persisted on localStorage
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function usePersistence(
|
||||
store: GlobalTimeStoreApi,
|
||||
persistKey: string | undefined,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!persistKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
set(persistKey, state.selectedTime);
|
||||
});
|
||||
}, [store, persistKey]);
|
||||
}
|
||||
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep lastRefreshTimestamp in sync after every react query refresh.
|
||||
* For named stores, only tracks queries with matching store name.
|
||||
* For unnamed stores, tracks all AUTO_REFRESH_QUERY queries (backward compatible).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
const storeName = store.getState().name;
|
||||
|
||||
return queryCache.subscribe((event) => {
|
||||
if (event?.type !== 'queryUpdated') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.action as { type?: string };
|
||||
if (action?.type !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryKey = event.query.queryKey;
|
||||
if (!Array.isArray(queryKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is created by getAutoRefreshQueryKey inside the store,
|
||||
// to track usages of global time store and autoRefresh
|
||||
if (queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Named store: only track queries with matching name at position [1]
|
||||
if (storeName && queryKey[1] !== storeName) {
|
||||
return;
|
||||
}
|
||||
// Unnamed store: track all AUTO_REFRESH_QUERY queries (backward compatible)
|
||||
store.getState().updateRefreshTimestamp();
|
||||
});
|
||||
}, [queryClient, store]);
|
||||
}
|
||||
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions } from './types';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
} from './utils';
|
||||
|
||||
interface UrlSyncConfig {
|
||||
relativeTimeKey: string;
|
||||
startTimeKey: string;
|
||||
endTimeKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to sync internal state with URL when URL params are enabled.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useUrlSync(
|
||||
store: GlobalTimeStoreApi,
|
||||
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
|
||||
removeOnUnmount: boolean,
|
||||
): void {
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const keys: UrlSyncConfig =
|
||||
enableUrlParams && typeof enableUrlParams === 'object'
|
||||
? {
|
||||
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
|
||||
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
|
||||
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
|
||||
}
|
||||
: {
|
||||
relativeTimeKey: 'relativeTime',
|
||||
startTimeKey: 'startTime',
|
||||
endTimeKey: 'endTime',
|
||||
};
|
||||
|
||||
const [urlState, setUrlState] = useQueryStates(
|
||||
{
|
||||
[keys.relativeTimeKey]: parseAsString,
|
||||
[keys.startTimeKey]: parseAsInteger,
|
||||
[keys.endTimeKey]: parseAsInteger,
|
||||
},
|
||||
{ history: 'replace' },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !isInitialMount.current) {
|
||||
return;
|
||||
}
|
||||
isInitialMount.current = false;
|
||||
|
||||
const relativeTime = urlState[keys.relativeTimeKey];
|
||||
const startTime = urlState[keys.startTimeKey];
|
||||
const endTime = urlState[keys.endTimeKey];
|
||||
|
||||
if (typeof startTime === 'number' && typeof endTime === 'number') {
|
||||
const customTime = createCustomTimeRange(
|
||||
startTime * NANO_SECOND_MULTIPLIER,
|
||||
endTime * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
store.getState().setSelectedTime(customTime);
|
||||
} else if (
|
||||
typeof relativeTime === 'string' &&
|
||||
isValidShortHandDateTimeFormat(relativeTime)
|
||||
) {
|
||||
store.getState().setSelectedTime(relativeTime as Time);
|
||||
}
|
||||
}, [
|
||||
urlState,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
store,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
const parsed = parseCustomTimeRange(state.selectedTime);
|
||||
if (parsed) {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
|
||||
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: state.selectedTime,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
store,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !removeOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
removeOnUnmount,
|
||||
keys?.relativeTimeKey,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
}
|
||||
@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
|
||||
}
|
||||
|
||||
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
|
||||
const minTime = parseInt(minStr, 10);
|
||||
const maxTime = parseInt(maxStr, 10);
|
||||
const minTime = Number.parseInt(minStr, 10);
|
||||
const maxTime = Number.parseInt(maxStr, 10);
|
||||
|
||||
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
|
||||
return null;
|
||||
@@ -79,11 +79,60 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to build your react-query key for auto-refresh queries
|
||||
* @deprecated Use store.getAutoRefreshQueryKey() instead.
|
||||
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* This function only works with the default (unnamed) store prefix.
|
||||
* For named stores, use the store method to get properly scoped query keys.
|
||||
*/
|
||||
export function getAutoRefreshQueryKey(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(
|
||||
'[globalTime] getAutoRefreshQueryKey from utils is deprecated. ' +
|
||||
'Use useGlobalTime((s) => s.getAutoRefreshQueryKey) instead.',
|
||||
);
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round timestamp down to the nearest 5-second boundary.
|
||||
* Used for tighter sync during auto-refresh scenarios.
|
||||
*
|
||||
* @param timestampNano - Timestamp in nanoseconds
|
||||
* @returns Timestamp rounded down to 5-second boundary in nanoseconds
|
||||
*/
|
||||
export function roundDownTo5Seconds(timestampNano: number): number {
|
||||
const msPerInterval = 5 * 1000;
|
||||
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
|
||||
const roundedMs = Math.floor(timestampMs / msPerInterval) * msPerInterval;
|
||||
return roundedMs * NANO_SECOND_MULTIPLIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute min/max time with maxTime rounded down to 5-second boundary.
|
||||
* Used when isRefreshEnabled is true for tighter time sync.
|
||||
*
|
||||
* @param selectedTime - The selected time (relative like '15m' or custom range)
|
||||
* @returns ParsedTimeRange with 5-second rounded maxTime for relative times
|
||||
*/
|
||||
export function computeRounded5sMinMax(selectedTime: string): ParsedTimeRange {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return parseSelectedTime(selectedTime);
|
||||
}
|
||||
|
||||
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const roundedMaxTime = roundDownTo5Seconds(nowNano);
|
||||
|
||||
const { minTime: originalMin, maxTime: originalMax } =
|
||||
getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
|
||||
const durationNano = originalMax - originalMin;
|
||||
|
||||
return {
|
||||
minTime: roundedMaxTime - durationNano,
|
||||
maxTime: roundedMaxTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19194,6 +19194,11 @@ use-sidecar@^1.1.3:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
|
||||
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
|
||||
Reference in New Issue
Block a user