fix(ui): update eslint

This commit is contained in:
Lukasz Mierzwa
2026-03-09 12:12:13 +00:00
committed by Łukasz Mierzwa
parent 3b74969b3c
commit 638531901a
83 changed files with 1307 additions and 3090 deletions

View File

@@ -1,85 +0,0 @@
const path = require("path");
const config = {
root: true,
ignorePatterns: ["playwright.config.ts", "src/e2e/"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
project: ["./tsconfig.json"],
tsconfigRootDir: __dirname,
},
plugins: [
"@typescript-eslint",
"prettier",
"react",
"react-hooks",
"jest",
],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jest/recommended",
"prettier",
],
settings: {
react: {
version: "detect",
},
},
env: {
browser: true,
es2022: true,
jest: true,
node: true,
},
rules: {
"react/prop-types": "off",
"react/display-name": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-expressions": [
"error",
{
allowShortCircuit: true,
allowTernary: true,
},
],
},
overrides: [
{
files: [
"**/__mocks__/*.ts",
"**/__mocks__/**/*.ts",
"**/*.test.ts",
"**/*.test.tsx",
],
rules: {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"jest/expect-expect": "off",
},
},
],
};
module.exports = config;

View File

@@ -48,8 +48,7 @@ update-e2e:
.PHONY: lint-js
lint-js: $(NODE_PATH)/eslint
@rm -fr node_modules/.cache/eslint-loader
eslint --ext .js,.jsx,.ts,.tsx src
eslint
tsc --noEmit -p .
.PHONY: lint-typescript

85
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,85 @@
import eslintJs from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import tseslint from "typescript-eslint";
import eslintReact from "@eslint-react/eslint-plugin";
import reactCompiler from "eslint-plugin-react-compiler";
import jest from "eslint-plugin-jest";
import prettier from "eslint-config-prettier";
export default defineConfig(
globalIgnores(["playwright.config.ts", "src/e2e/", "coverage/"]),
{
files: ["src/**/*.ts", "src/**/*.tsx"],
extends: [
eslintJs.configs.recommended,
...tseslint.configs.recommended,
eslintReact.configs["recommended-typescript"],
],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-expressions": [
"error",
{
allowShortCircuit: true,
allowTernary: true,
},
],
// setState in useEffect is the standard pattern for async fetches, MobX reactions, and timer-driven updates
"@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "off",
// index keys are used in static display-only lists (split strings, SVG samples, error messages) with no stable ID
"@eslint-react/no-array-index-key": "off",
// cloneElement is used by react-transition-group integration, replacing it would require a major refactor
"@eslint-react/no-clone-element": "off",
// Children.map is used by the Toast container with react-transition-group TransitionGroup
"@eslint-react/no-children-map": "off",
// dangerouslySetInnerHTML is intentional for rendering trusted server-provided HTML
"@eslint-react/dom/no-dangerously-set-innerhtml": "off",
},
},
{
files: ["src/**/*.ts", "src/**/*.tsx"],
...reactCompiler.configs.recommended,
},
{
files: [
"src/**/__mocks__/*.ts",
"src/**/__mocks__/**/*.ts",
"src/**/*.test.ts",
"src/**/*.test.tsx",
],
...jest.configs["flat/recommended"],
rules: {
...jest.configs["flat/recommended"].rules,
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"jest/expect-expect": "off",
"react-compiler/react-compiler": "off",
"@eslint-react/no-create-ref": "off",
"@eslint-react/no-nested-component-definitions": "off",
},
},
prettier,
);

2967
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,8 @@
"typeface-open-sans": "1.1.13"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "2.13.0",
"@eslint/js": "10.0.1",
"@fetch-mock/jest": "0.2.20",
"@playwright/test": "1.58.2",
"@testing-library/jest-dom": "6.9.1",
@@ -61,17 +63,13 @@
"@types/promise-retry": "1.1.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-legacy": "7.2.1",
"@vitejs/plugin-react": "5.1.4",
"csstype": "3.2.3",
"eslint": "8.57.1",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"fetch-mock": "12.6.0",
"identity-obj-proxy": "3.0.0",
"jest": "30.2.0",
@@ -82,6 +80,7 @@
"terser": "5.46.0",
"ts-jest": "29.4.6",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
},

View File

@@ -30,9 +30,11 @@ interface AppProps {
}
const App: FC<AppProps> = observer(({ defaultFilters, uiDefaults }) => {
const [alertStore] = useState<AlertStore>(new AlertStore(null));
const [silenceFormStore] = useState<SilenceFormStore>(new SilenceFormStore());
const [settingsStore] = useState<Settings>(new Settings(uiDefaults));
const [alertStore] = useState<AlertStore>(() => new AlertStore(null));
const [silenceFormStore] = useState<SilenceFormStore>(
() => new SilenceFormStore(),
);
const [settingsStore] = useState<Settings>(() => new Settings(uiDefaults));
useEffect(() => {
let filters;
@@ -61,7 +63,7 @@ const App: FC<AppProps> = observer(({ defaultFilters, uiDefaults }) => {
if (p.params.m && silenceFormStore.data.fromBase64(p.params.m)) {
silenceFormStore.toggle.show();
}
}, [alertStore, defaultFilters, settingsStore]); // eslint-disable-line react-hooks/exhaustive-deps
}, [alertStore, defaultFilters, settingsStore]);
const onPopState = useCallback(
(event: PopStateEvent) => {
@@ -85,7 +87,7 @@ const App: FC<AppProps> = observer(({ defaultFilters, uiDefaults }) => {
return (
<ErrorBoundary>
<span data-theme={`${settingsStore.themeConfig.config.theme}`} />
<ThemeContext.Provider
<ThemeContext
value={{
isDark:
settingsStore.themeConfig.config.theme ===
@@ -122,7 +124,7 @@ const App: FC<AppProps> = observer(({ defaultFilters, uiDefaults }) => {
/>
<FaviconBadge alertStore={alertStore} />
</React.Suspense>
</ThemeContext.Provider>
</ThemeContext>
</ErrorBoundary>
);
});

View File

@@ -2,7 +2,6 @@
// https://stackoverflow.com/a/58208791/1154047
const normalize = (): any => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (_: string, value: any) {
if (typeof value === "object" && !Array.isArray(value)) {
return Object.entries(value)

View File

@@ -32,7 +32,7 @@ export const AlertHistory: FC<{ group: APIAlertGroupT; grid: APIGridT }> = ({
}) => {
const [ref, inView] = useInView({ triggerOnce: true });
const [lastUpdate, setLastUpdate] = useState<number>(GetUTCSeconds());
const [lastUpdate, setLastUpdate] = useState<number>(() => GetUTCSeconds());
const [upstreams, setUpstreams] = useState<UpstreamT[]>([]);
const [labels] = useState<{ [key: string]: string }>({
...Object.fromEntries(group.labels.map((l) => [l.name, l.value])),

View File

@@ -1,4 +1,4 @@
import React, { FC, useRef } from "react";
import React, { use, FC, useRef } from "react";
import { CSSTransition } from "react-transition-group";
@@ -9,7 +9,7 @@ const DropdownSlide: FC<{
in?: boolean;
unmountOnExit?: boolean;
}> = ({ children, ...props }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const nodeRef = useRef<HTMLDivElement>(null);
return (

View File

@@ -5,11 +5,7 @@ import { ThemeContext } from "Components/Theme";
import { CenteredMessage } from ".";
const renderWithTheme = (ui: React.ReactElement) =>
render(
<ThemeContext.Provider value={MockThemeContext}>
{ui}
</ThemeContext.Provider>,
);
render(<ThemeContext value={MockThemeContext}>{ui}</ThemeContext>);
describe("<CenteredMessage />", () => {
const Message = () => <div>Foo</div>;

View File

@@ -1,4 +1,4 @@
import React, { FC, ReactNode, useRef } from "react";
import { use, FC, ReactNode, useRef } from "react";
import { CSSTransition } from "react-transition-group";
@@ -8,7 +8,7 @@ const CenteredMessage: FC<{
children: ReactNode;
className?: string;
}> = ({ children, className }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const nodeRef = useRef<HTMLHeadingElement>(null);
return (
<CSSTransition

View File

@@ -16,7 +16,7 @@ const formatLabel = (timestamp: string) => {
};
export const DateFromNow: FC<{ timestamp: string }> = ({ timestamp }) => {
const [label, setLabel] = useState<string>(formatLabel(timestamp));
const [label, setLabel] = useState<string>(() => formatLabel(timestamp));
useEffect(() => {
const timer = setInterval(

View File

@@ -10,12 +10,13 @@ const FaviconBadge: FC<{
alertStore: AlertStore;
}> = ({ alertStore }) => {
const [favico] = useState(
new Favico({
animation: "none",
position: "down",
bgColor: "#e74c3c",
textColor: "#fff",
}),
() =>
new Favico({
animation: "none",
position: "down",
bgColor: "#e74c3c",
textColor: "#fff",
}),
);
useEffect(
@@ -29,7 +30,7 @@ const FaviconBadge: FC<{
: "?",
);
}),
[], // eslint-disable-line react-hooks/exhaustive-deps
[],
);
return null;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, FC } from "react";
import { use, useEffect, useRef, useState, FC } from "react";
import { reaction, toJS } from "mobx";
import { observer } from "mobx-react-lite";
@@ -18,7 +18,7 @@ import { ThemeContext } from "Components/Theme";
import { TooltipWrapper } from "Components/TooltipWrapper";
const PauseButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const nodeRef = useRef<HTMLSpanElement>(null);
return (
<TooltipWrapper title="Click to resume updates">
@@ -42,7 +42,7 @@ const PauseButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => {
};
const PlayButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const nodeRef = useRef<HTMLSpanElement>(null);
return (
<TooltipWrapper title="Click to pause updates">
@@ -101,7 +101,7 @@ const Fetcher: FC<{
alertStore: AlertStore;
settingsStore: Settings;
}> = observer(({ alertStore, settingsStore }) => {
const timer = useRef<number | undefined>(undefined);
const timerRef = useRef<number | undefined>(undefined);
const [percentLeft, setPercentLeft] = useState<number>(100);
const [isHover, setIsHover] = useState(false);
@@ -191,7 +191,7 @@ const Fetcher: FC<{
};
useEffect(() => {
return () => window.clearInterval(timer.current);
return () => window.clearInterval(timerRef.current);
}, []);
useEffect(
@@ -224,7 +224,7 @@ const Fetcher: FC<{
},
{ fireImmediately: true },
),
[], // eslint-disable-line react-hooks/exhaustive-deps
[],
);
useEffect(
@@ -233,10 +233,10 @@ const Fetcher: FC<{
() => alertStore.status.paused,
(paused) => {
if (paused) {
window.clearInterval(timer.current);
timer.current = undefined;
window.clearInterval(timerRef.current);
timerRef.current = undefined;
} else {
timer.current = window.setInterval(
timerRef.current = window.setInterval(
() => window.requestAnimationFrame(fetchIfIdle),
1000,
);
@@ -244,7 +244,7 @@ const Fetcher: FC<{
},
{ fireImmediately: true },
),
[], // eslint-disable-line react-hooks/exhaustive-deps
[],
);
const dots = Math.max(0, Math.min(9, percentLeft / 10));

View File

@@ -1,11 +1,4 @@
import {
forwardRef,
Ref,
CSSProperties,
useRef,
useState,
useCallback,
} from "react";
import { Ref, CSSProperties, useRef, useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
@@ -73,112 +66,110 @@ interface MenuContentProps {
afterClick: () => void;
alertStore: AlertStore;
silenceFormStore: SilenceFormStore;
ref?: Ref<HTMLDivElement>;
}
const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(
(
{
x,
y,
floating,
strategy,
group,
alert,
afterClick,
alertStore,
silenceFormStore,
},
ref,
) => {
const actions: APIAnnotationT[] = [
...alert.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true),
...group.shared.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true),
];
const MenuContent = ({
x,
y,
floating,
strategy,
group,
alert,
afterClick,
alertStore,
silenceFormStore,
ref,
}: MenuContentProps) => {
const actions: APIAnnotationT[] = [
...alert.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true),
...group.shared.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true),
];
return (
<FetchPauser alertStore={alertStore}>
return (
<FetchPauser alertStore={alertStore}>
<div
className="dropdown-menu d-block shadow m-0"
ref={(node) => {
if (typeof floating === "function") {
floating(node);
} else if (floating) {
// eslint-disable-next-line react-compiler/react-compiler -- assigning to floating ref in a ref callback is an intentional pattern from @floating-ui
floating.current = node;
}
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
style={{
position: strategy,
top: y,
left: x,
}}
>
<h6 className="dropdown-header">Alert source links:</h6>
{alert.alertmanager.map((am) => (
<MenuLink
key={am.name}
icon={faExternalLinkAlt}
text={am.name}
uri={am.source}
afterClick={afterClick}
/>
))}
<div className="dropdown-divider" />
<div
className="dropdown-menu d-block shadow m-0"
ref={(node) => {
if (typeof floating === "function") {
floating(node);
} else if (floating) {
floating.current = node;
}
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
style={{
position: strategy,
top: y,
left: x,
className="dropdown-item cursor-pointer"
onClick={() => {
copy(JSON.stringify(alertToJSON(group, alert)));
afterClick();
}}
>
<h6 className="dropdown-header">Alert source links:</h6>
{alert.alertmanager.map((am) => (
<MenuLink
key={am.name}
icon={faExternalLinkAlt}
text={am.name}
uri={am.source}
afterClick={afterClick}
/>
))}
<div className="dropdown-divider" />
<div
className="dropdown-item cursor-pointer"
onClick={() => {
copy(JSON.stringify(alertToJSON(group, alert)));
afterClick();
}}
>
<FontAwesomeIcon className="me-1" icon={faCopy} />
Copy to clipboard
</div>
{actions.length ? (
<>
<div className="dropdown-divider" />
<h6 className="dropdown-header">Actions:</h6>
{actions.map((action) => (
<MenuLink
key={action.name}
icon={faWrench}
text={action.name}
uri={action.value}
afterClick={afterClick}
/>
))}
</>
) : null}
<div className="dropdown-divider" />
<div
className={`dropdown-item ${
Object.keys(alertStore.data.clustersWithoutReadOnly).length === 0
? "disabled"
: "cursor-pointer"
}`}
onClick={() => {
if (Object.keys(alertStore.data.clustersWithoutReadOnly).length) {
onSilenceClick(alertStore, silenceFormStore, group, alert);
afterClick();
}
}}
>
<FontAwesomeIcon className="me-1" icon={faBellSlash} />
Silence this alert
</div>
<FontAwesomeIcon className="me-1" icon={faCopy} />
Copy to clipboard
</div>
</FetchPauser>
);
},
);
{actions.length ? (
<>
<div className="dropdown-divider" />
<h6 className="dropdown-header">Actions:</h6>
{actions.map((action) => (
<MenuLink
key={action.name}
icon={faWrench}
text={action.name}
uri={action.value}
afterClick={afterClick}
/>
))}
</>
) : null}
<div className="dropdown-divider" />
<div
className={`dropdown-item ${
Object.keys(alertStore.data.clustersWithoutReadOnly).length === 0
? "disabled"
: "cursor-pointer"
}`}
onClick={() => {
if (Object.keys(alertStore.data.clustersWithoutReadOnly).length) {
onSilenceClick(alertStore, silenceFormStore, group, alert);
afterClick();
}
}}
>
<FontAwesomeIcon className="me-1" icon={faBellSlash} />
Silence this alert
</div>
</div>
</FetchPauser>
);
};
interface AlertMenuProps {
group: APIAlertGroupT;

View File

@@ -56,7 +56,7 @@ const renderAlert = (
showOnlyExpandedAnnotations: boolean,
) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Alert
alert={alert}
group={group}
@@ -67,7 +67,7 @@ const renderAlert = (
silenceFormStore={silenceFormStore}
setIsMenuOpen={MockSetIsMenuOpen}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -31,7 +31,7 @@ const RenderNonLinkAnnotation: FC<{
}
});
const { ref, props, nodeRef } = useFlashTransition(value);
const { ref, props } = useFlashTransition(value);
const className =
"mb-1 p-1 bg-light d-inline-block rounded components-grid-annotation text-break mw-100";
@@ -63,21 +63,11 @@ const RenderNonLinkAnnotation: FC<{
<CSSTransition {...props}>
{allowHTML ? (
<span
ref={(node) => {
ref(node);
nodeRef.current = node;
}}
ref={ref}
dangerouslySetInnerHTML={{ __html: value }}
></span>
) : (
<span
ref={(node) => {
ref(node);
nodeRef.current = node;
}}
>
{value}
</span>
<span ref={ref}>{value}</span>
)}
</CSSTransition>
</Linkify>

View File

@@ -68,7 +68,7 @@ const renderGroupFooter = (props?: {
showAnnotations?: boolean;
}) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<GroupFooter
group={group}
afterUpdate={MockAfterUpdate}
@@ -76,7 +76,7 @@ const renderGroupFooter = (props?: {
silenceFormStore={silenceFormStore}
{...props}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -89,7 +89,7 @@ const MockAlerts = (alertCount: number, totalAlerts: number) => {
const renderAlertGroup = (afterUpdate: () => void, theme?: ThemeCtx) => {
return render(
<ThemeContext.Provider value={theme || MockThemeContext}>
<ThemeContext value={theme || MockThemeContext}>
<AlertGroup
afterUpdate={afterUpdate}
grid={grid}
@@ -100,7 +100,7 @@ const renderAlertGroup = (afterUpdate: () => void, theme?: ThemeCtx) => {
gridLabelValue=""
groupWidth={420}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,6 +1,7 @@
import React, {
import {
use,
FC,
forwardRef,
Ref,
useEffect,
useCallback,
useState,
@@ -55,214 +56,209 @@ interface AlertGroupProps {
silenceFormStore: SilenceFormStore;
groupWidth: number;
gridLabelValue: string;
ref?: Ref<HTMLDivElement>;
}
const AlertGroup = forwardRef<HTMLDivElement, AlertGroupProps>(
(
{
grid,
group,
afterUpdate,
silenceFormStore,
alertStore,
settingsStore,
groupWidth,
gridLabelValue,
},
ref,
) => {
const defaultRenderCount =
settingsStore.alertGroupConfig.config.defaultRenderCount;
const AlertGroup = ({
grid,
group,
afterUpdate,
silenceFormStore,
alertStore,
settingsStore,
groupWidth,
gridLabelValue,
ref,
}: AlertGroupProps) => {
const defaultRenderCount =
settingsStore.alertGroupConfig.config.defaultRenderCount;
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(
DefaultDetailsCollapseValue(settingsStore),
const [isCollapsed, setIsCollapsed] = useState<boolean>(() =>
DefaultDetailsCollapseValue(settingsStore),
);
// Used to calculate step size when loading more alerts.
// Step is calculated from the excessive alert count
// (what's > defaultRenderCount) by dividing it into 5 clicks.
// Don't use step lower than 5, too much clicking if we have a group of 9:
// * we'll show initially 5
// * step would be 1
// * 4 extra clicks to see the entire group
// but ensure that step wouldn't push us above totalSize
// With 9 alerts and rendering 5 initially we want to show extra 9 after one
// click, and when user clicks showLess we want to go back to 5.
const getStepSize = (totalSize: number) => {
const val = Math.min(
Math.max(Math.round((totalSize - defaultRenderCount) / 5), 5),
totalSize - defaultRenderCount,
);
return val;
};
// Used to calculate step size when loading more alerts.
// Step is calculated from the excessive alert count
// (what's > defaultRenderCount) by dividing it into 5 clicks.
// Don't use step lower than 5, too much clicking if we have a group of 9:
// * we'll show initially 5
// * step would be 1
// * 4 extra clicks to see the entire group
// but ensure that step wouldn't push us above totalSize
// With 9 alerts and rendering 5 initially we want to show extra 9 after one
// click, and when user clicks showLess we want to go back to 5.
const getStepSize = (totalSize: number) => {
const val = Math.min(
Math.max(Math.round((totalSize - defaultRenderCount) / 5), 5),
totalSize - defaultRenderCount,
);
return val;
};
const loadMore = () => {
const step = getStepSize(group.totalAlerts);
alertStore.ui.setGroupAlertLimit(
group.id,
Math.min(group.alerts.length + step, group.totalAlerts),
);
};
const loadLess = () => {
const step = getStepSize(group.totalAlerts);
alertStore.ui.setGroupAlertLimit(
group.id,
Math.max(group.alerts.length - step, 1),
);
};
const onAlertGroupCollapseEvent = useCallback(
(event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail.gridLabelValue === gridLabelValue) {
setIsCollapsed(customEvent.detail.value);
}
},
[gridLabelValue],
const loadMore = () => {
const step = getStepSize(group.totalAlerts);
alertStore.ui.setGroupAlertLimit(
group.id,
Math.min(group.alerts.length + step, group.totalAlerts),
);
};
useEffect(() => {
window.addEventListener("alertGroupCollapse", onAlertGroupCollapseEvent);
return () => {
window.removeEventListener(
"alertGroupCollapse",
onAlertGroupCollapseEvent,
);
};
}, [onAlertGroupCollapseEvent]);
const loadLess = () => {
const step = getStepSize(group.totalAlerts);
alertStore.ui.setGroupAlertLimit(
group.id,
Math.max(group.alerts.length - step, 1),
);
};
useEffect(() => {
afterUpdate();
});
let themedCounters = true;
let cardBackgroundClass = "bg-light";
if (settingsStore.alertGroupConfig.config.colorTitleBar) {
const stateList = Object.entries(group.stateCount)
.filter(([_, v]) => v !== 0)
.map(([k, _]) => k);
if (stateList.length === 1) {
const state = stateList.pop();
cardBackgroundClass = BackgroundClassMap[state as AlertStateT];
themedCounters = false;
const onAlertGroupCollapseEvent = useCallback(
(event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail.gridLabelValue === gridLabelValue) {
setIsCollapsed(customEvent.detail.value);
}
},
[gridLabelValue],
);
useEffect(() => {
window.addEventListener("alertGroupCollapse", onAlertGroupCollapseEvent);
return () => {
window.removeEventListener(
"alertGroupCollapse",
onAlertGroupCollapseEvent,
);
};
}, [onAlertGroupCollapseEvent]);
useEffect(() => {
afterUpdate();
});
let themedCounters = true;
let cardBackgroundClass = "bg-light";
if (settingsStore.alertGroupConfig.config.colorTitleBar) {
const stateList = Object.entries(group.stateCount)
.filter(([_, v]) => v !== 0)
.map(([k, _]) => k);
if (stateList.length === 1) {
const state = stateList.pop();
cardBackgroundClass = BackgroundClassMap[state as AlertStateT];
themedCounters = false;
}
}
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
return (
<div
ref={ref}
className={`components-grid-alertgrid-alertgroup ${
context.animations.duration ? "animate" : ""
}`}
style={{
width: groupWidth,
zIndex: isMenuOpen ? 100 : undefined,
}}
data-defaultrendercount={
settingsStore.alertGroupConfig.config.defaultRenderCount
}
>
<div
ref={ref}
className={`components-grid-alertgrid-alertgroup ${
context.animations.duration ? "animate" : ""
}`}
style={{
width: groupWidth,
zIndex: isMenuOpen ? 100 : undefined,
}}
data-defaultrendercount={
settingsStore.alertGroupConfig.config.defaultRenderCount
}
className={`card ${cardBackgroundClass}`}
data-colortitlebar={settingsStore.alertGroupConfig.config.colorTitleBar}
>
<div
className={`card ${cardBackgroundClass}`}
data-colortitlebar={
settingsStore.alertGroupConfig.config.colorTitleBar
}
>
<GroupHeader
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
<GroupHeader
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
group={group}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
themedCounters={themedCounters}
setIsMenuOpen={setIsMenuOpen}
gridLabelValue={gridLabelValue}
/>
{isCollapsed ? null : (
<div className="card-body px-2 py-1 components-grid-alertgrid-card">
{alertStore.settings.values.historyEnabled ? (
<AlertHistory group={group} grid={grid} />
) : null}
<ul className="list-group">
{group.alerts
.slice(0, alertStore.ui.isIdle ? 1 : group.alerts.length)
.map((alert) => (
<Alert
key={alert.id}
group={group}
alert={alert}
showReceiver={
alertStore.data.receivers.length > 1 &&
group.alerts.length === 1
}
showOnlyExpandedAnnotations={alertStore.ui.isIdle}
afterUpdate={afterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
setIsMenuOpen={setIsMenuOpen}
/>
))}
{group.totalAlerts > defaultRenderCount ? (
<li
className="list-group-item border-0 p-0 text-center bg-transparent"
style={{
lineHeight: alertStore.ui.isIdle ? "1rem" : undefined,
}}
>
{alertStore.ui.isIdle ? (
<FontAwesomeIcon
icon={faEllipsisH}
className="text-muted"
/>
) : (
<>
<LoadButton
icon={faMinus}
action={loadLess}
tooltip="Show fewer alerts in this group"
/>
<small className="text-muted mx-2">
{group.alerts.length}
{" of "}
{group.totalAlerts}
</small>
<LoadButton
icon={faPlus}
action={loadMore}
tooltip="Show more alerts in this group"
/>
</>
)}
</li>
) : null}
</ul>
</div>
)}
{isCollapsed === false ? (
<GroupFooter
group={group}
afterUpdate={afterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
themedCounters={themedCounters}
setIsMenuOpen={setIsMenuOpen}
gridLabelValue={gridLabelValue}
showAnnotations={!alertStore.ui.isIdle}
showSilences={!alertStore.ui.isIdle}
showReceiver={
!(
alertStore.data.receivers.length > 1 &&
group.alerts.length === 1
)
}
/>
{isCollapsed ? null : (
<div className="card-body px-2 py-1 components-grid-alertgrid-card">
{alertStore.settings.values.historyEnabled ? (
<AlertHistory group={group} grid={grid} />
) : null}
<ul className="list-group">
{group.alerts
.slice(0, alertStore.ui.isIdle ? 1 : group.alerts.length)
.map((alert) => (
<Alert
key={alert.id}
group={group}
alert={alert}
showReceiver={
alertStore.data.receivers.length > 1 &&
group.alerts.length === 1
}
showOnlyExpandedAnnotations={alertStore.ui.isIdle}
afterUpdate={afterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
setIsMenuOpen={setIsMenuOpen}
/>
))}
{group.totalAlerts > defaultRenderCount ? (
<li
className="list-group-item border-0 p-0 text-center bg-transparent"
style={{
lineHeight: alertStore.ui.isIdle ? "1rem" : undefined,
}}
>
{alertStore.ui.isIdle ? (
<FontAwesomeIcon
icon={faEllipsisH}
className="text-muted"
/>
) : (
<>
<LoadButton
icon={faMinus}
action={loadLess}
tooltip="Show fewer alerts in this group"
/>
<small className="text-muted mx-2">
{group.alerts.length}
{" of "}
{group.totalAlerts}
</small>
<LoadButton
icon={faPlus}
action={loadMore}
tooltip="Show more alerts in this group"
/>
</>
)}
</li>
) : null}
</ul>
</div>
)}
{isCollapsed === false ? (
<GroupFooter
group={group}
afterUpdate={afterUpdate}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
showAnnotations={!alertStore.ui.isIdle}
showSilences={!alertStore.ui.isIdle}
showReceiver={
!(
alertStore.data.receivers.length > 1 &&
group.alerts.length === 1
)
}
/>
) : null}
</div>
) : null}
</div>
);
},
);
</div>
);
};
export default observer(AlertGroup);

View File

@@ -1,4 +1,5 @@
import React, {
use,
FC,
useEffect,
useState,
@@ -132,12 +133,12 @@ const Grid: FC<{
paddingTop,
zIndex,
}) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const { ref, repack } = useGrid(gridSizesConfig);
const debouncedRepack = useMemo(() => debounce(() => repack(), 10), [repack]);
const [isExpanded, setIsExpanded] = useState<boolean>(
!DefaultDetailsCollapseValue(settingsStore),
() => !DefaultDetailsCollapseValue(settingsStore),
);
const toggleIsExpanded = useCallback(() => {
setIsExpanded(!isExpanded);

View File

@@ -199,13 +199,13 @@ describe("<GridLabelSelect />", () => {
},
]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContextWithoutAnimations}>
<ThemeContext value={MockThemeContextWithoutAnimations}>
<AlertGrid
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
const toggle = container.querySelector(

View File

@@ -1,4 +1,5 @@
import React, {
import {
use,
FC,
Ref,
CSSProperties,
@@ -64,7 +65,7 @@ const GridLabelNameSelect: FC<{
callback(options);
};
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<AsyncSelect

View File

@@ -1,4 +1,4 @@
import { forwardRef, MouseEvent } from "react";
import { MouseEvent, Ref } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGrip } from "@fortawesome/free-solid-svg-icons/faGrip";
@@ -19,83 +19,87 @@ interface SwimlaneProps {
isExpanded: boolean;
onToggle: (event: MouseEvent) => void;
paddingTop: number;
ref?: Ref<HTMLHeadingElement>;
}
const Swimlane = forwardRef<HTMLHeadingElement, SwimlaneProps>(
(
{ alertStore, settingsStore, grid, isExpanded, onToggle, paddingTop },
ref,
) => {
return (
<h5
ref={ref}
className="components-grid-swimlane d-flex flex-row justify-content-between rounded px-2 py-1 my-1 border border-dark"
style={{ top: paddingTop }}
>
<span className="flex-shrink-0 flex-grow-0 d-none d-sm-block">
<span className="badge components-label px-0 ms-1 me-3">
<FontAwesomeIcon icon={faGrip} className="text-muted" />
</span>
const Swimlane = ({
alertStore,
settingsStore,
grid,
isExpanded,
onToggle,
paddingTop,
ref,
}: SwimlaneProps) => {
return (
<h5
ref={ref}
className="components-grid-swimlane d-flex flex-row justify-content-between rounded px-2 py-1 my-1 border border-dark"
style={{ top: paddingTop }}
>
<span className="flex-shrink-0 flex-grow-0 d-none d-sm-block">
<span className="badge components-label px-0 ms-1 me-3">
<FontAwesomeIcon icon={faGrip} className="text-muted" />
</span>
</span>
<span
className="flex-shrink-1 flex-grow-0 ms-1 ms-sm-0"
style={{ minWidth: "0px" }}
>
{grid.labelName !== "" && grid.labelValue !== "" && (
<FilteringLabel
key={grid.labelValue}
name={grid.labelName}
value={grid.labelValue}
alertStore={alertStore}
/>
)}
</span>
{grid.labelName !== "" && grid.labelValue !== "" && (
<span
className="flex-shrink-1 flex-grow-0 ms-1 ms-sm-0"
className="flex-shrink-0 flex-grow-1 px-0"
style={{ minWidth: "0px" }}
>
{grid.labelName !== "" && grid.labelValue !== "" && (
<FilteringLabel
key={grid.labelValue}
name={grid.labelName}
value={grid.labelValue}
alertStore={alertStore}
/>
)}
<GridLabelSelect
alertStore={alertStore}
settingsStore={settingsStore}
grid={grid}
/>
</span>
{grid.labelName !== "" && grid.labelValue !== "" && (
<span
className="flex-shrink-0 flex-grow-1 px-0"
style={{ minWidth: "0px" }}
>
<GridLabelSelect
alertStore={alertStore}
settingsStore={settingsStore}
grid={grid}
/>
</span>
)}
<span className="flex-shrink-0 flex-grow-0 ms-2 me-0">
<FilteringCounterBadge
name="@state"
value="unprocessed"
counter={grid.stateCount.unprocessed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="suppressed"
counter={grid.stateCount.suppressed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="active"
counter={grid.stateCount.active}
themed={true}
alertStore={alertStore}
/>
<span
className="text-muted cursor-pointer badge with-click with-click-dark components-label ms-1 me-0"
onClick={onToggle}
>
<TooltipWrapper title="Click to toggle this grid details or Alt+Click to toggle all grids">
<ToggleIcon isOpen={isExpanded} />
</TooltipWrapper>
</span>
)}
<span className="flex-shrink-0 flex-grow-0 ms-2 me-0">
<FilteringCounterBadge
name="@state"
value="unprocessed"
counter={grid.stateCount.unprocessed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="suppressed"
counter={grid.stateCount.suppressed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="active"
counter={grid.stateCount.active}
themed={true}
alertStore={alertStore}
/>
<span
className="text-muted cursor-pointer badge with-click with-click-dark components-label ms-1 me-0"
onClick={onToggle}
>
<TooltipWrapper title="Click to toggle this grid details or Alt+Click to toggle all grids">
<ToggleIcon isOpen={isExpanded} />
</TooltipWrapper>
</span>
</h5>
);
},
);
</span>
</h5>
);
};
export { Swimlane };

View File

@@ -66,13 +66,13 @@ afterEach(() => {
const renderAlertGrid = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<AlertGrid
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};
@@ -94,7 +94,7 @@ const MockGrid = () => ({
const renderGrid = (theme?: ThemeCtx) => {
return render(
<ThemeContext.Provider value={theme || MockThemeContext}>
<ThemeContext value={theme || MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -106,7 +106,7 @@ const renderGrid = (theme?: ThemeCtx) => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};
@@ -227,7 +227,7 @@ describe("<Grid />", () => {
it("appends more groups after clicking 'Load More' button", () => {
MockGroupList(40, 5, 70);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -239,7 +239,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
const button = container.querySelector("button");
fireEvent.click(button!);
@@ -263,7 +263,7 @@ describe("<Grid />", () => {
},
]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -275,7 +275,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
const button = container.querySelector("button");
fireEvent.click(button!);
@@ -309,7 +309,7 @@ describe("<Grid />", () => {
};
alertStore.data.setGrids([grid]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -321,7 +321,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(
container.querySelectorAll(".components-grid-alertgrid-alertgroup"),
@@ -358,7 +358,7 @@ describe("<Grid />", () => {
]);
const dispatchSpy = jest.spyOn(window, "dispatchEvent");
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -370,7 +370,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
const toggles = container.querySelectorAll("span.cursor-pointer");
@@ -396,7 +396,7 @@ describe("<Grid />", () => {
};
alertStore.data.setGrids([grid]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -408,7 +408,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(
@@ -441,7 +441,7 @@ describe("<Grid />", () => {
};
alertStore.data.setGrids([MockGrid(), MockGrid()]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -453,7 +453,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(container.textContent).toMatch(/foo:.*bar/);
});
@@ -470,7 +470,7 @@ describe("<Grid />", () => {
};
alertStore.data.setGrids([MockGrid(), MockGrid()]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
@@ -482,7 +482,7 @@ describe("<Grid />", () => {
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(container.querySelector("h5")?.innerHTML).not.toMatch(/foo: bar/);
});

View File

@@ -25,10 +25,10 @@ const AlertGrid: FC<{
const { width: windowWidth } = useWindowSize();
const { observe, width: bodyWidth } = useDimensions();
const [gridSizesConfig, setGridSizesConfig] = useState<SizeDetail[]>(
const [gridSizesConfig, setGridSizesConfig] = useState<SizeDetail[]>(() =>
GridSizesConfig(settingsStore.gridConfig.config.groupWidth),
);
const [groupWidth, setGroupWidth] = useState<number>(
const [groupWidth, setGroupWidth] = useState<number>(() =>
GetGridElementWidth(
bodyWidth || document.body.clientWidth,
windowWidth,
@@ -52,7 +52,7 @@ const AlertGrid: FC<{
),
);
}),
[windowWidth, bodyWidth], // eslint-disable-line react-hooks/exhaustive-deps
[windowWidth, bodyWidth],
);
useHotkeys("alt+space", alertStore.status.togglePause);

View File

@@ -7,9 +7,9 @@ import { EmptyGrid } from ".";
describe("<EmptyGrid />", () => {
it("matches snapshot", () => {
const { asFragment } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<EmptyGrid />
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@@ -7,9 +7,9 @@ import { FatalError } from ".";
describe("<FatalError />", () => {
it("matches snapshot", () => {
const { asFragment } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<FatalError message="foo bar" />
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@@ -7,9 +7,9 @@ import { NoUpstream } from ".";
describe("<NoUpstream />", () => {
it("matches snapshot", () => {
const { asFragment } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<NoUpstream />
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@@ -17,11 +17,7 @@ afterEach(() => {
});
const renderWithTheme = (ui: React.ReactElement) =>
render(
<ThemeContext.Provider value={MockThemeContext}>
{ui}
</ThemeContext.Provider>,
);
render(<ThemeContext value={MockThemeContext}>{ui}</ThemeContext>);
describe("<ReloadNeeded />", () => {
it("matches snapshot", () => {

View File

@@ -6,16 +6,12 @@ import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { CenteredMessage } from "Components/CenteredMessage";
const clearReloadTimer = (timer: NodeJS.Timeout): void => {
clearTimeout(timer);
};
const ReloadNeeded: FC<{
reloadAfter: number;
}> = ({ reloadAfter }) => {
useEffect(() => {
const timer = setTimeout(() => window.location.reload(), reloadAfter);
return () => clearReloadTimer(timer);
return () => clearTimeout(timer);
}, [reloadAfter]);
return (

View File

@@ -17,11 +17,7 @@ afterEach(() => {
});
const renderWithTheme = (ui: React.ReactElement) =>
render(
<ThemeContext.Provider value={MockThemeContext}>
{ui}
</ThemeContext.Provider>,
);
render(<ThemeContext value={MockThemeContext}>{ui}</ThemeContext>);
describe("<UpgradeNeeded />", () => {
it("matches snapshot", () => {

View File

@@ -8,17 +8,13 @@ import { CenteredMessage } from "Components/CenteredMessage";
import "csshake/dist/csshake-slow.css";
const clearUpgradeTimer = (timer: NodeJS.Timeout): void => {
clearTimeout(timer);
};
const UpgradeNeeded: FC<{
newVersion: string;
reloadAfter: number;
}> = ({ newVersion, reloadAfter }) => {
useEffect(() => {
const timer = setTimeout(() => window.location.reload(), reloadAfter);
return () => clearUpgradeTimer(timer);
return () => clearTimeout(timer);
}, [reloadAfter]);
return (

View File

@@ -32,7 +32,7 @@ const FilteringCounterBadge: FC<{
defaultColor = "bg-light",
isAppend = true,
}) => {
const { ref, props, nodeRef } = useFlashTransition(counter);
const { ref, props } = useFlashTransition(counter);
const handleClick = useCallback(
(event: MouseEvent) => {
@@ -72,10 +72,7 @@ const FilteringCounterBadge: FC<{
>
<CSSTransition {...props}>
<span
ref={(node) => {
ref(node);
nodeRef.current = node;
}}
ref={ref}
className={
themed
? cs.className

View File

@@ -13,9 +13,9 @@ beforeEach(() => {
const renderConfiguration = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<AlertGroupCollapseConfiguration settingsStore={settingsStore} />
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import { observer } from "mobx-react-lite";
@@ -32,7 +32,7 @@ const AlertGroupCollapseConfiguration: FC<{
settingsStore.alertGroupConfig.setDefaultCollapseState(newValue);
};
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<div className="mb-0">

View File

@@ -18,9 +18,9 @@ afterEach(() => {
const renderConfiguration = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<AlertGroupSortConfiguration settingsStore={settingsStore} />
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import { observer } from "mobx-react-lite";
@@ -43,7 +43,7 @@ const AlertGroupSortConfiguration: FC<{
settingsStore.gridConfig.config.sortOrder ===
settingsStore.gridConfig.options.disabled.value;
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<div className="mb-0">

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import Creatable from "react-select/creatable";
@@ -33,7 +33,7 @@ const GridLabelName: FC<{
FormatBackendURI(`labelNames.json`),
);
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const defaultValue =
settingsStore.multiGridConfig.config.gridLabel === "@auto"

View File

@@ -24,9 +24,9 @@ afterEach(() => {
const renderConfiguration = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<MultiGridConfiguration settingsStore={settingsStore} />
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import Creatable from "react-select/creatable";
@@ -23,7 +23,7 @@ const SortLabelName: FC<{
settingsStore.gridConfig.setSortLabel(StaticLabels.AlertName);
}
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<Creatable

View File

@@ -13,9 +13,9 @@ beforeEach(() => {
const renderConfiguration = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<ThemeConfiguration settingsStore={settingsStore} />
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import { observer } from "mobx-react-lite";
@@ -32,7 +32,7 @@ const ThemeConfiguration: FC<{
settingsStore.themeConfig.setTheme(newValue);
};
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<div className="mb-2">

View File

@@ -24,9 +24,9 @@ describe("<Configuration />", () => {
it("matches snapshot", () => {
const settingsStore = new Settings(null);
const { asFragment } = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Configuration settingsStore={settingsStore} defaultIsOpen={true} />
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@@ -29,14 +29,14 @@ afterEach(() => {
const renderModalContent = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<MainModalContent
alertStore={alertStore}
settingsStore={settingsStore}
onHide={onHide}
expandAllOptions={true}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -46,9 +46,9 @@ const renderMainModal = async () => {
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<MainModal alertStore={alertStore} settingsStore={settingsStore} />
</ThemeContext.Provider>,
</ThemeContext>,
);
});
return result!;

View File

@@ -30,16 +30,13 @@ const SilenceIDCopyButton: FC<{
id: string;
}> = ({ id }) => {
const [clickCount, setClickCount] = useState<number>(0);
const { ref, props, nodeRef } = useFlashTransition(clickCount);
const { ref, props } = useFlashTransition(clickCount);
return (
<TooltipWrapper title="Copy silence ID to the clipboard">
<CSSTransition {...props}>
<span
ref={(node) => {
ref(node);
nodeRef.current = node;
}}
ref={ref}
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
onClick={() => {
copy(id);

View File

@@ -53,7 +53,7 @@ afterEach(() => {
const renderManagedSilence = (onDidUpdate?: () => void) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<ManagedSilence
cluster={cluster}
alertCount={123}
@@ -63,7 +63,7 @@ const renderManagedSilence = (onDidUpdate?: () => void) => {
silenceFormStore={silenceFormStore}
onDidUpdate={onDidUpdate}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -64,7 +64,7 @@ const ManagedSilence: FC<{
silenceFormStore.toggle.show();
};
const [progress, setProgress] = useState<number>(
const [progress, setProgress] = useState<number>(() =>
calculatePercent(silence.startsAt, silence.endsAt),
);

View File

@@ -116,11 +116,11 @@ describe("<ModalInner />", () => {
it("uses components-animation-modal class when animations are enabled", () => {
const onExited = jest.fn();
render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Modal isOpen={true} toggleOpen={fakeToggle} onExited={onExited}>
<div />
</Modal>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(document.body.querySelector(".modal")).toBeInTheDocument();
});
@@ -128,11 +128,11 @@ describe("<ModalInner />", () => {
it("doesn't use components-animation-modal class when animations are disabled", () => {
const onExited = jest.fn();
render(
<ThemeContext.Provider value={MockThemeContextWithoutAnimations}>
<ThemeContext value={MockThemeContextWithoutAnimations}>
<Modal isOpen={true} toggleOpen={fakeToggle} onExited={onExited}>
<div />
</Modal>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(document.body.querySelector(".modal")).toBeInTheDocument();
});

View File

@@ -1,4 +1,4 @@
import React, { FC, useEffect, useRef } from "react";
import React, { use, FC, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { CSSTransition } from "react-transition-group";
@@ -64,7 +64,7 @@ const Modal: FC<{
onExited,
children,
}) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const modalRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);

View File

@@ -191,7 +191,7 @@ const History: FC<{
alertStore: AlertStore;
settingsStore: Settings;
}> = observer(({ alertStore, settingsStore }) => {
const [history] = useState<HistoryStorage>(new HistoryStorage());
const [history] = useState<HistoryStorage>(() => new HistoryStorage());
const [isVisible, setIsVisible] = useState<boolean>(false);
const [maxHeight, setMaxHeight] = useState<number | null>(null);
const hide = useCallback(() => setIsVisible(false), []);
@@ -246,7 +246,7 @@ const History: FC<{
disposeAutorun();
history.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const ref = useRef<HTMLSpanElement | null>(null);
useOnClickOutside(ref, hide, isVisible);

View File

@@ -96,14 +96,14 @@ afterEach(() => {
const renderNavbar = (fixedTop?: boolean) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
fixedTop={fixedTop}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};
@@ -215,13 +215,13 @@ describe("<NavBar />", () => {
resizeCallback([{ contentRect: { width: 100, height: 10 } }]);
});
rerender(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(
window
@@ -233,13 +233,13 @@ describe("<NavBar />", () => {
resizeCallback([{ contentRect: { width: 100, height: 36 } }]);
});
rerender(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
expect(
window

View File

@@ -1,4 +1,4 @@
import React, { FC, useState, useEffect, useCallback, useRef } from "react";
import { use, FC, useState, useEffect, useCallback, useRef } from "react";
import { reaction } from "mobx";
import { observer } from "mobx-react-lite";
@@ -30,7 +30,7 @@ const NavBar: FC<{
}> = ({ alertStore, settingsStore, silenceFormStore, fixedTop = true }) => {
const [containerClass, setContainerClass] = useState<string>("visible");
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const ref = useRef<HTMLElement>(null);
const { observe, height } = useDimensions({});
@@ -38,6 +38,7 @@ const NavBar: FC<{
const updateBodyPaddingTop = useCallback(
(idle: boolean) => {
const paddingTop = idle ? 0 : height + 8;
// eslint-disable-next-line react-compiler/react-compiler -- intentional DOM side-effect, not React state
document.body.style.paddingTop = `${paddingTop}px`;
setContainerClass(idle ? "invisible" : "visible");
@@ -93,7 +94,7 @@ const NavBar: FC<{
(paused) => (paused ? pause() : reset()),
{ fireImmediately: true },
),
[], // eslint-disable-line react-hooks/exhaustive-deps
[],
);
const navRef = useRef<HTMLElement>(null);

View File

@@ -26,19 +26,14 @@ const OverviewModal: FC<{
const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]);
const { ref, props, nodeRef } = useFlashTransition(
alertStore.info.totalAlerts,
);
const { ref, props } = useFlashTransition(alertStore.info.totalAlerts);
return (
<>
<TooltipWrapper title="Show alert overview">
<CSSTransition {...props}>
<div
ref={(node) => {
ref(node);
nodeRef.current = node;
}}
ref={ref}
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
isVisible ? "border-info" : ""
}`}

View File

@@ -63,12 +63,12 @@ beforeEach(() => {
const renderAlertManagerInput = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<AlertManagerInput
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC, useEffect } from "react";
import { use, FC, useEffect } from "react";
import { autorun } from "mobx";
import { observer } from "mobx-react-lite";
@@ -36,7 +36,7 @@ const AlertManagerInput: FC<{
}),
);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
useEffect(
// https://mobx-react.netlify.app/recipes-effects
@@ -59,10 +59,10 @@ const AlertManagerInput: FC<{
}
}
}),
[], // eslint-disable-line react-hooks/exhaustive-deps
[],
);
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<Select

View File

@@ -235,16 +235,19 @@ export const MassDeleteProgress: FC<{
}
};
const timers: ReturnType<typeof setTimeout>[] = [];
silences.forEach((silence, index) => {
const ams = alertStore.data.readWriteAlertmanagers.filter(
(u) => u.cluster === silence.cluster,
);
setTimeout(
// eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- false positive: timers are collected and cleared in the useEffect cleanup below
const timer = setTimeout(
() => deleteSilence(silence.cluster, silence.id, ams),
50 * index,
);
timers.push(timer);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
return () => timers.forEach((t) => clearTimeout(t));
}, []);
return (

View File

@@ -83,13 +83,13 @@ const MockSilenceList = (count: number): APIManagedSilenceT[] => {
const renderBrowser = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<Browser
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,5 @@
import React, {
import {
use,
FC,
useState,
useEffect,
@@ -46,7 +47,7 @@ const FetchError: FC<{
const Placeholder: FC<{
content: ReactNode;
}> = ({ content }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
const nodeRef = useRef<HTMLDivElement>(null);
return (

View File

@@ -246,7 +246,7 @@ const DateTimeSelect: FC<{
openTab?: tabT;
}> = observer(({ silenceFormStore, openTab = "duration" }) => {
const [currentTab, setCurrentTab] = useState<tabT>(openTab);
const [timeNow, setTimeNow] = useState<Date>(nowZeroSeconds());
const [timeNow, setTimeNow] = useState<Date>(() => nowZeroSeconds());
const updateTimeNow = useCallback(() => {
setTimeNow(nowZeroSeconds());

View File

@@ -51,14 +51,14 @@ beforeEach(() => {
const renderSilenceForm = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<SilenceForm
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
previewOpen={false}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -162,7 +162,7 @@ const SilenceForm: FC<{
if (alertStore.info.authentication.enabled) {
silenceFormStore.data.setAuthor(alertStore.info.authentication.username);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const addMore = (event: MouseEvent) => {
event.preventDefault();

View File

@@ -21,9 +21,9 @@ afterEach(() => {
const renderLabelNameInput = (isValid: boolean) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<LabelNameInput matcher={matcher} isValid={isValid} />
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { use, FC } from "react";
import Creatable from "react-select/creatable";
@@ -19,7 +19,7 @@ const LabelNameInput: FC<{
FormatBackendURI(`labelNames.json`),
);
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return (
<Creatable
@@ -33,6 +33,7 @@ const LabelNameInput: FC<{
}
placeholder={isValid ? "Label name" : <ValidationError />}
onChange={(option: OnChangeValue<OptionT, false>) => {
// eslint-disable-next-line react-compiler/react-compiler -- intentional MobX observable mutation
matcher.name = (option as OptionT).value;
}}
hideSelectedOptions

View File

@@ -26,13 +26,13 @@ afterEach(() => {
const renderLabelValueInput = (isValid: boolean) => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<LabelValueInput
silenceFormStore={silenceFormStore}
matcher={matcher}
isValid={isValid}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -1,4 +1,4 @@
import React, { FC, useEffect } from "react";
import { use, FC, useEffect } from "react";
import { observer } from "mobx-react-lite";
@@ -41,6 +41,27 @@ const Placeholder = (props: PlaceholderProps<OptionT, true>) => (
</div>
);
const makeValueContainer = (
silenceFormStore: SilenceFormStore,
matcher: MatcherWithIDT,
) =>
function ValueContainer(props: ValueContainerProps<OptionT, true>) {
return (
<components.ValueContainer
{...(props as ValueContainerProps<OptionT, true>)}
>
{matcher.values.length > 0 ? (
<MatchCounter
key={GenerateHashFromMatchers(silenceFormStore, matcher)}
silenceFormStore={silenceFormStore}
matcher={matcher}
/>
) : null}
{props.children}
</components.ValueContainer>
);
};
const LabelValueInput: FC<{
silenceFormStore: SilenceFormStore;
matcher: MatcherWithIDT;
@@ -58,22 +79,7 @@ const LabelValueInput: FC<{
return () => cancelGet();
}, [matcher.name, get, cancelGet]);
const context = React.useContext(ThemeContext);
const ValueContainer = (props: ValueContainerProps<OptionT, true>) => (
<components.ValueContainer
{...(props as ValueContainerProps<OptionT, true>)}
>
{matcher.values.length > 0 ? (
<MatchCounter
key={GenerateHashFromMatchers(silenceFormStore, matcher)}
silenceFormStore={silenceFormStore}
matcher={matcher}
/>
) : null}
{props.children}
</components.ValueContainer>
);
const context = use(ThemeContext);
return (
<Creatable
@@ -104,7 +110,7 @@ const LabelValueInput: FC<{
hideSelectedOptions
isMulti
components={{
ValueContainer: ValueContainer,
ValueContainer: makeValueContainer(silenceFormStore, matcher),
Placeholder: Placeholder,
Menu: AnimatedMenuMultiple,
}}

View File

@@ -49,6 +49,7 @@ const SilenceMatch: FC<{
value=""
checked={matcher.isEqual}
onChange={(event) => {
// eslint-disable-next-line react-compiler/react-compiler -- intentional MobX observable mutation
matcher.isEqual = event.target.checked;
}}
/>

View File

@@ -49,14 +49,14 @@ const MockOnHide = jest.fn();
const renderSilenceModalContent = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<SilenceModalContent
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
onHide={MockOnHide}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -73,7 +73,7 @@ const SilenceSubmitProgress: FC<{
}`,
});
}
}, [cluster, error, inProgress, publicURIs, response, responseURI]); // eslint-disable-line react-hooks/exhaustive-deps
}, [cluster, error, inProgress, publicURIs, response, responseURI]);
return <FontAwesomeIcon className="text-muted" icon={faCircleNotch} spin />;
};

View File

@@ -47,13 +47,13 @@ afterEach(() => {
const renderSilenceModal = () => {
return render(
<ThemeContext.Provider value={MockThemeContext}>
<ThemeContext value={MockThemeContext}>
<SilenceModal
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
/>
</ThemeContext.Provider>,
</ThemeContext>,
);
};

View File

@@ -2,32 +2,27 @@ import React, { act } from "react";
import { render } from "@testing-library/react";
import { BodyTheme } from ".";
const context = {
isDark: true,
};
import { ThemeContext, BodyTheme } from ".";
import type { ThemeCtx } from ".";
beforeEach(() => {
document.body.classList.remove("theme-light");
document.body.classList.remove("theme-dark");
document.documentElement.removeAttribute("data-bs-theme");
jest.spyOn(React, "useContext").mockImplementation(() => {
return context;
});
});
afterEach(() => {
jest.resetAllMocks();
});
const renderWithTheme = (ctx: Partial<ThemeCtx>) =>
render(
<ThemeContext value={ctx as ThemeCtx}>
<BodyTheme />
</ThemeContext>,
);
describe("<BodyTheme />", () => {
it("uses light theme when ThemeContext->isDark is false", async () => {
// Verifies body class and data-bs-theme attribute are set for light theme when isDark is false
context.isDark = false;
await act(async () => {
render(<BodyTheme />);
renderWithTheme({ isDark: false });
});
expect(document.body.classList.contains("theme-light")).toEqual(true);
expect(document.documentElement.getAttribute("data-bs-theme")).toEqual(
@@ -37,9 +32,8 @@ describe("<BodyTheme />", () => {
it("uses dark theme when ThemeContext->isDark is true", async () => {
// Verifies body class and data-bs-theme attribute are set for dark theme when isDark is true
context.isDark = true;
await act(async () => {
render(<BodyTheme />);
renderWithTheme({ isDark: true });
});
expect(document.body.classList.contains("theme-dark")).toEqual(true);
expect(document.documentElement.getAttribute("data-bs-theme")).toEqual(
@@ -49,10 +43,9 @@ describe("<BodyTheme />", () => {
it("updates theme when ThemeContext->isDark is updated", async () => {
// Verifies body class updates when isDark context value changes
context.isDark = true;
let rerender: (ui: React.ReactElement) => void;
await act(async () => {
const result = render(<BodyTheme />);
const result = renderWithTheme({ isDark: true });
rerender = result.rerender;
});
expect(document.body.classList.contains("theme-dark")).toEqual(true);
@@ -60,9 +53,12 @@ describe("<BodyTheme />", () => {
document.body.classList.remove("theme-light");
document.body.classList.remove("theme-dark");
context.isDark = false;
await act(async () => {
rerender!(<BodyTheme />);
rerender!(
<ThemeContext value={{ isDark: false } as ThemeCtx}>
<BodyTheme />
</ThemeContext>,
);
});
expect(document.body.classList.contains("theme-light")).toEqual(true);

View File

@@ -1,4 +1,4 @@
import React, { FC, useEffect } from "react";
import React, { use, FC, useEffect } from "react";
import type { StylesConfig } from "react-select/dist/declarations/src/styles";
@@ -18,7 +18,7 @@ const ThemeContext = React.createContext({
} as ThemeCtx);
const BodyTheme: FC = () => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
useEffect(() => {
document.documentElement.setAttribute(

View File

@@ -1,4 +1,4 @@
import React, { FC, ReactNode, useState, useEffect, useRef } from "react";
import React, { use, FC, ReactNode, useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import TransitionGroup from "react-transition-group/TransitionGroup";
@@ -77,7 +77,7 @@ const ToastTransition: FC<{
};
const ToastContainer: FC<{ children: ReactNode }> = ({ children }) => {
const context = React.useContext(ThemeContext);
const context = use(ThemeContext);
return ReactDOM.createPortal(
<div className="components-toast-container d-flex flex-column">

View File

@@ -73,13 +73,18 @@ describe("<ErrorBoundary />", () => {
});
it("reloadApp decrements countdown when more than one second is left", () => {
// Verifies that reloadApp uses functional setState to decrement reloadSeconds
const boundary = new ErrorBoundary({ children: <span /> });
const setStateSpy = jest.spyOn(boundary, "setState");
(boundary as any).state = { cachedError: null, reloadSeconds: 2 };
boundary.reloadApp();
expect(setStateSpy).toHaveBeenCalledWith({ reloadSeconds: 1 });
expect(setStateSpy).toHaveBeenCalledTimes(1);
const updater = setStateSpy.mock.calls[0][0] as unknown as (prev: {
reloadSeconds: number;
}) => { reloadSeconds: number };
expect(updater({ reloadSeconds: 2 })).toEqual({ reloadSeconds: 1 });
});
it("reloadApp does not decrement when countdown reaches 1 or less", () => {

View File

@@ -64,7 +64,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
if (this.state.reloadSeconds <= 1) {
window.location.reload();
} else {
this.setState({ reloadSeconds: this.state.reloadSeconds - 1 });
this.setState((prev) => ({ reloadSeconds: prev.reloadSeconds - 1 }));
}
};

View File

@@ -57,7 +57,7 @@ const useFetchDelete = (
return () => {
isCancelled = true;
};
}, [uri, options, ...deps]); // eslint-disable-line react-hooks/exhaustive-deps
}, [uri, options, ...deps]);
return { response, error, isDeleting };
};

View File

@@ -46,14 +46,14 @@ const useFetchGet = <T>(
isRetrying: false,
retryCount: 0,
});
const isCanceled = useRef<boolean>(false);
const isCanceledRef = useRef<boolean>(false);
const cancelGet = useCallback(() => {
isCanceled.current = true;
isCanceledRef.current = true;
}, []);
const get = useCallback(async () => {
isCanceled.current = false;
isCanceledRef.current = false;
try {
setResponse((r) => ({
@@ -78,7 +78,7 @@ const useFetchGet = <T>(
},
) as RequestInit,
).catch((err: Error) => {
if (!isCanceled.current) {
if (!isCanceledRef.current) {
setResponse((r) => ({
...r,
isRetrying: true,
@@ -90,7 +90,7 @@ const useFetchGet = <T>(
FetchRetryConfig,
);
if (res !== undefined && !isCanceled.current) {
if (res !== undefined && !isCanceledRef.current) {
let body;
const contentType = res.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
@@ -99,7 +99,7 @@ const useFetchGet = <T>(
body = await res.text();
}
if (!isCanceled.current) {
if (!isCanceledRef.current) {
if (res.ok) {
setResponse({
response: body,
@@ -134,7 +134,7 @@ const useFetchGet = <T>(
if (autorun) get();
return () => cancelGet();
}, [uri, get, cancelGet, autorun, ...deps]); // eslint-disable-line react-hooks/exhaustive-deps
}, [uri, get, cancelGet, autorun, ...deps]);
return { get, cancelGet, ...response };
};

View File

@@ -1,4 +1,11 @@
import { useState, useEffect, useRef, ReactNode, RefObject } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
ReactNode,
RefObject,
} from "react";
import type { CSSTransitionProps } from "react-transition-group/CSSTransition";
@@ -16,7 +23,7 @@ const defaultProps: CSSTransitionProps = {
const useFlashTransition = (
flashOn: ReactNode,
): {
ref: (node?: Element | null) => void;
ref: (node: HTMLElement | null) => void;
props: CSSTransitionProps;
nodeRef: RefObject<HTMLElement | null>;
} => {
@@ -45,7 +52,15 @@ const useFlashTransition = (
});
}, [inView, isPending]);
return { ref, props, nodeRef };
const combinedRef = useCallback(
(node: HTMLElement | null) => {
ref(node);
nodeRef.current = node;
},
[ref],
);
return { ref: combinedRef, props, nodeRef };
};
export { useFlashTransition, defaultProps };

View File

@@ -6,27 +6,28 @@ const useGrid = (
sizes: SizeDetail[],
): { ref: Ref<HTMLDivElement>; repack: () => void } => {
const ref = useRef<HTMLDivElement | null>(null);
const grid = useRef<Instance | null>(null);
const gridRef = useRef<Instance | null>(null);
const [repack, setRepack] = useState<() => void>(() => () => {});
useEffect(() => {
if (!grid.current && ref.current) {
grid.current = Bricks({
if (!gridRef.current && ref.current) {
gridRef.current = Bricks({
container: ref.current,
sizes: sizes,
packed: "packed",
position: false,
});
window.addEventListener("resize", grid.current.pack);
grid.current.pack();
window.addEventListener("resize", gridRef.current.pack);
gridRef.current.pack();
setRepack(() => () => {
grid.current && grid.current.pack();
gridRef.current && gridRef.current.pack();
});
}
return () => {
if (grid.current) window.removeEventListener("resize", grid.current.pack);
grid.current = null;
if (gridRef.current)
window.removeEventListener("resize", gridRef.current.pack);
gridRef.current = null;
};
}, [sizes]);

View File

@@ -10,8 +10,8 @@ describe("useOnClickOutside", () => {
enabled: boolean;
}> = ({ enabled }) => {
const ref = useRef<HTMLDivElement | null>(null);
const [isModalOpen, setModalOpen] = useState<boolean>(true);
useOnClickOutside(ref, () => setModalOpen(false), enabled);
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
useOnClickOutside(ref, () => setIsModalOpen(false), enabled);
return (
<div>

View File

@@ -13,7 +13,7 @@ function getSize(): Dimensions {
}
function useWindowSize(): Dimensions {
const [windowSize, setWindowSize] = useState<Dimensions>(getSize());
const [windowSize, setWindowSize] = useState<Dimensions>(() => getSize());
useEffect(() => {
const handleResize = () => {

View File

@@ -234,8 +234,6 @@ const useFetchGetMock = (
useEffect(() => {
if (autorun) get();
// eslint doesn't like ...deps
// eslint-disable-next-line
}, [uri, get, cancelGet, autorun, ...deps]);
return {