fix(ui): code fixes

This commit is contained in:
Lukasz Mierzwa
2026-02-24 17:42:57 +00:00
committed by Łukasz Mierzwa
parent 0e3110c0a2
commit 33ce78b8f0
42 changed files with 964 additions and 194 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ require (
github.com/klauspost/compress v1.18.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/env/v2 v2.0.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/providers/posflag v1.0.1
github.com/knadh/koanf/v2 v2.3.2
+2 -2
View File
@@ -99,8 +99,8 @@ github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1y
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/env/v2 v2.0.0 h1:Ad5H3eun722u+FvchiIcEIJZsZ2M6oxCkgZfWN5B5KY=
github.com/knadh/koanf/providers/env/v2 v2.0.0/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
+48 -46
View File
@@ -17,7 +17,7 @@ import (
"github.com/go-viper/mapstructure/v2"
yamlParser "github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
@@ -212,51 +212,53 @@ func readEnvVariables(k *koanf.Koanf) {
}
}
_ = k.Load(env.Provider("", ".", func(s string) string {
switch s {
case "ALERTMANAGER_EXTERNAL_URI":
return "alertmanager.external_uri"
case "ALERTMANAGER_TLS_INSECURE_SKIP_VERIFY":
return "alertmanager.tls.insecureSkipVerify"
case "ALERTACKNOWLEDGEMENT_ENABLED":
return "alertAcknowledgement.enabled"
case "ALERTACKNOWLEDGEMENT_DURATION":
return "alertAcknowledgement.duration"
case "ALERTACKNOWLEDGEMENT_AUTHOR":
return "alertAcknowledgement.author"
case "ALERTACKNOWLEDGEMENT_COMMENT":
return "alertAcknowledgement.comment"
case "ANNOTATIONS_ENABLEINSECUREHTML":
return "annotations.enableInsecureHTML"
case "AUTHENTICATION_HEADER_VALUE_RE":
return "authentication.header.value_re"
case "GRID_GROUPLIMIT":
return "grid.groupLimit"
case "LABELS_KEEP_RE":
return "labels.keep_re"
case "LABELS_STRIP_RE":
return "labels.strip_re"
case "LABELS_VALUEONLY":
return "labels.valueOnly"
case "LABELS_VALUEONLY_RE":
return "labels.valueOnly_re"
case "SILENCEFORM_STRIP_LABELS":
return "silenceForm.strip.labels"
case "SILENCEFORM_DEFAULTALERTMANAGERS":
return "silenceForm.defaultAlertmanagers"
case "UI_HIDEFILTERSWHENIDLE":
return "ui.hideFiltersWhenIdle"
case "UI_COLORTITLEBAR":
return "ui.colorTitlebar"
case "UI_MINIMALGROUPWIDTH":
return "ui.minimalGroupWidth"
case "UI_ALERTSPERGROUP":
return "ui.alertsPerGroup"
case "UI_COLLAPSEGROUPS":
return "ui.collapseGroups"
default:
return strings.ReplaceAll(strings.ToLower(s), "_", ".")
}
_ = k.Load(env.Provider(".", env.Opt{
TransformFunc: func(s, v string) (string, any) {
switch s {
case "ALERTMANAGER_EXTERNAL_URI":
return "alertmanager.external_uri", v
case "ALERTMANAGER_TLS_INSECURE_SKIP_VERIFY":
return "alertmanager.tls.insecureSkipVerify", v
case "ALERTACKNOWLEDGEMENT_ENABLED":
return "alertAcknowledgement.enabled", v
case "ALERTACKNOWLEDGEMENT_DURATION":
return "alertAcknowledgement.duration", v
case "ALERTACKNOWLEDGEMENT_AUTHOR":
return "alertAcknowledgement.author", v
case "ALERTACKNOWLEDGEMENT_COMMENT":
return "alertAcknowledgement.comment", v
case "ANNOTATIONS_ENABLEINSECUREHTML":
return "annotations.enableInsecureHTML", v
case "AUTHENTICATION_HEADER_VALUE_RE":
return "authentication.header.value_re", v
case "GRID_GROUPLIMIT":
return "grid.groupLimit", v
case "LABELS_KEEP_RE":
return "labels.keep_re", v
case "LABELS_STRIP_RE":
return "labels.strip_re", v
case "LABELS_VALUEONLY":
return "labels.valueOnly", v
case "LABELS_VALUEONLY_RE":
return "labels.valueOnly_re", v
case "SILENCEFORM_STRIP_LABELS":
return "silenceForm.strip.labels", v
case "SILENCEFORM_DEFAULTALERTMANAGERS":
return "silenceForm.defaultAlertmanagers", v
case "UI_HIDEFILTERSWHENIDLE":
return "ui.hideFiltersWhenIdle", v
case "UI_COLORTITLEBAR":
return "ui.colorTitlebar", v
case "UI_MINIMALGROUPWIDTH":
return "ui.minimalGroupWidth", v
case "UI_ALERTSPERGROUP":
return "ui.alertsPerGroup", v
case "UI_COLLAPSEGROUPS":
return "ui.collapseGroups", v
default:
return strings.ReplaceAll(strings.ToLower(s), "_", "."), v
}
},
}), nil)
}
+84
View File
@@ -0,0 +1,84 @@
const path = require("path");
const config = {
root: true,
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;
-56
View File
@@ -1,56 +0,0 @@
root: true
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 2022
sourceType: module
ecmaFeatures:
jsx: true
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-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
+21
View File
@@ -78,6 +78,15 @@ describe("ParseDefaultFilters()", () => {
const filters = FiltersSetting("foo=bar");
expect(filters).toHaveLength(0);
});
it("ignores template placeholder values", () => {
// Scenario: default filters attribute rendered with the literal template placeholder should be ignored
const settings = document.createElement("span");
(settings as any).dataset = {
defaultFiltersBase64: "{{ .DefaultFilter }}",
};
expect(ParseDefaultFilters(settings)).toHaveLength(0);
});
});
describe("ParseUIDefaults()", () => {
@@ -99,4 +108,16 @@ describe("ParseUIDefaults()", () => {
} as HTMLElement);
expect(uiDefaults).toBeNull();
});
it("returns null when base64 decoding fails", () => {
// Scenario: malformed base64 input results in an exception during decoding
const atobSpy = jest.spyOn(window, "atob").mockImplementation(() => {
throw new Error("boom");
});
const uiDefaults = ParseUIDefaults({
innerHTML: "###",
} as HTMLElement);
expect(uiDefaults).toBeNull();
atobSpy.mockRestore();
});
});
+4 -4
View File
@@ -172,13 +172,13 @@ const AlertAck: FC<{
}}
>
{!isAcking && error ? (
<FontAwesomeIcon icon={faExclamationCircle} fixedWidth />
<FontAwesomeIcon icon={faExclamationCircle} className="fa-fw" />
) : !isAcking && response ? (
<FontAwesomeIcon icon={faCheckCircle} fixedWidth />
<FontAwesomeIcon icon={faCheckCircle} className="fa-fw" />
) : isAcking ? (
<FontAwesomeIcon icon={faSpinner} fixedWidth spin />
<FontAwesomeIcon icon={faSpinner} spin className="fa-fw" />
) : (
<FontAwesomeIcon icon={faCheck} fixedWidth />
<FontAwesomeIcon icon={faCheck} className="fa-fw" />
)}
</span>
</TooltipWrapper>
@@ -586,8 +586,8 @@ describe("<AlertHistory />", () => {
await fetchMock.callHistory.flush(true);
});
const rects = Array.from(container.querySelectorAll("rect")).map(
(r) => r.className.baseVal,
const rects = Array.from(container.querySelectorAll("rect")).map((r) =>
r.getAttribute("class"),
);
expect(rects).toStrictEqual(testCase.values);
unmount();
+2 -4
View File
@@ -31,9 +31,8 @@ const PauseButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => {
>
<span ref={nodeRef} className="d-inline-block">
<FontAwesomeIcon
className="cursor-pointer text-muted components-fetcher-icon mx-2"
className="cursor-pointer text-muted components-fetcher-icon mx-2 fa-fw"
icon={faPause}
fixedWidth
onClick={alertStore.status.resume}
/>
</span>
@@ -56,9 +55,8 @@ const PlayButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => {
>
<span ref={nodeRef} className="d-inline-block">
<FontAwesomeIcon
className="cursor-pointer text-muted components-fetcher-icon mx-2"
className="cursor-pointer text-muted components-fetcher-icon mx-2 fa-fw"
icon={faPlay}
fixedWidth
onClick={alertStore.status.pause}
/>
</span>
@@ -1,4 +1,4 @@
import { act } from "react";
import { act, createRef } from "react";
import { render, fireEvent } from "@testing-library/react";
@@ -290,4 +290,88 @@ describe("<MenuContent />", () => {
JSON.stringify(alertToJSON(group, alert)),
);
});
it("supports floating callback refs", () => {
const floatingRef = jest.fn();
const forwardedRef = createRef<HTMLDivElement>();
render(
<MenuContent
ref={forwardedRef}
x={123}
y={456}
floating={floatingRef}
strategy="absolute"
group={group}
alert={alert}
afterClick={MockAfterClick}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>,
);
expect(floatingRef).toHaveBeenCalledWith(expect.any(HTMLDivElement));
expect(forwardedRef.current).toBeInstanceOf(HTMLDivElement);
});
it("supports floating object refs", () => {
const floatingObjectRef = createRef<HTMLDivElement>();
const forwardedRef = createRef<HTMLDivElement>();
render(
<MenuContent
ref={forwardedRef}
x={321}
y={654}
floating={floatingObjectRef}
strategy="absolute"
group={group}
alert={alert}
afterClick={MockAfterClick}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>,
);
expect(floatingObjectRef.current).toBeInstanceOf(HTMLDivElement);
expect(forwardedRef.current).toBeInstanceOf(HTMLDivElement);
});
it("invokes forwarded callback refs", () => {
const floatingObjectRef = createRef<HTMLDivElement>();
const forwardedRef = jest.fn();
render(
<MenuContent
ref={forwardedRef}
x={11}
y={22}
floating={floatingObjectRef}
strategy="absolute"
group={group}
alert={alert}
afterClick={MockAfterClick}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
/>,
);
expect(forwardedRef).toHaveBeenCalledWith(expect.any(HTMLDivElement));
expect(floatingObjectRef.current).toBeInstanceOf(HTMLDivElement);
});
it("ignores silence click when all clusters are read-only", () => {
const upstreams = generateUpstreams();
upstreams.instances.forEach((instance) => {
instance.readonly = true;
});
alertStore.data.setUpstreams(upstreams);
const { container } = renderMenuContent(group);
const buttons = container.querySelectorAll(".dropdown-item");
fireEvent.click(buttons[2]);
expect(silenceFormStore.toggle.visible).toBe(false);
expect(MockAfterClick).not.toHaveBeenCalled();
});
});
@@ -64,8 +64,8 @@ const onSilenceClick = (
};
interface MenuContentProps {
x: number | null;
y: number | null;
x: number;
y: number;
floating: Ref<HTMLDivElement> | null;
strategy: CSSProperties["position"];
group: APIAlertGroupT;
@@ -104,25 +104,21 @@ const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(
<div
className="dropdown-menu d-block shadow m-0"
ref={(node) => {
// Handle both the floating ref and the forwarded ref
if (typeof floating === "function") {
floating(node);
} else if (floating) {
(
floating as React.MutableRefObject<HTMLDivElement | null>
).current = node;
floating.current = node;
}
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
ref.current = node;
}
}}
style={{
position: strategy,
top: y ?? "",
left: x ?? "",
top: y,
left: x,
}}
>
<h6 className="dropdown-header">Alert source links:</h6>
@@ -65,9 +65,7 @@ const RenderNonLinkAnnotation: FC<{
<span
ref={(node) => {
ref(node);
(
nodeRef as React.MutableRefObject<HTMLElement | null>
).current = node;
nodeRef.current = node;
}}
dangerouslySetInnerHTML={{ __html: value }}
></span>
@@ -75,9 +73,7 @@ const RenderNonLinkAnnotation: FC<{
<span
ref={(node) => {
ref(node);
(
nodeRef as React.MutableRefObject<HTMLElement | null>
).current = node;
nodeRef.current = node;
}}
>
{value}
@@ -50,8 +50,8 @@ const onSilenceClick = (
};
const MenuContent: FC<{
x: number | null;
y: number | null;
x: number;
y: number;
floating: Ref<HTMLDivElement> | null;
strategy: CSSProperties["position"];
group: APIAlertGroupT;
@@ -93,8 +93,8 @@ const MenuContent: FC<{
ref={floating}
style={{
position: strategy,
top: y ?? "",
left: x ?? "",
top: y,
left: x,
}}
>
{actions.length ? (
@@ -15,6 +15,26 @@ import { ThemeContext } from "Components/Theme";
import AlertGrid from ".";
import { GridLabelSelect } from "./GridLabelSelect";
jest.mock("@floating-ui/react-dom", () => {
const actual = jest.requireActual("@floating-ui/react-dom");
return {
...actual,
useFloating: jest.fn(() => ({
x: 150,
y: 275,
refs: {
setReference: jest.fn(),
setFloating: jest.fn(),
},
strategy: "absolute",
update: jest.fn(),
placement: "bottom",
middlewareData: {},
context: {},
})),
};
});
let alertStore: AlertStore;
let settingsStore: Settings;
let silenceFormStore: SilenceFormStore;
@@ -206,4 +226,27 @@ describe("<GridLabelSelect />", () => {
await act(() => promise);
});
it("passes floating coordinates into dropdown styles", async () => {
const promise = Promise.resolve();
const { container } = renderGridLabelSelect();
const toggle = container.querySelector(
"span.components-grid-label-select-dropdown",
);
fireEvent.click(toggle!);
act(() => {
jest.runOnlyPendingTimers();
});
const dropdown = container.querySelector(
"div.components-grid-label-select-menu",
) as HTMLElement;
expect(dropdown.style.top).toBe("275px");
expect(dropdown.style.left).toBe("150px");
expect(dropdown.style.position).toBe("absolute");
await act(() => promise);
});
});
@@ -89,8 +89,8 @@ const GridLabelNameSelect: FC<{
};
const Dropdown: FC<{
x: number | null;
y: number | null;
x: number;
y: number;
floating: Ref<HTMLDivElement> | null;
strategy: CSSProperties["position"];
alertStore: AlertStore;
@@ -115,8 +115,8 @@ const Dropdown: FC<{
fontSize: "1rem",
fontWeight: "normal",
position: strategy,
top: y ?? "",
left: x ?? "",
top: y,
left: x,
}}
>
<GridLabelNameSelect
@@ -345,6 +345,90 @@ describe("<Grid />", () => {
).toHaveLength(10);
});
it("dispatches alertGridCollapse event on alt + click", () => {
jest.useFakeTimers();
MockGroupList(5, 1);
alertStore.data.setGrids([
{
...alertStore.data.grids[0],
labelName: "foo",
labelValue: "bar",
},
]);
const dispatchSpy = jest.spyOn(window, "dispatchEvent");
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
gridSizesConfig={GridSizesConfig(420)}
groupWidth={420}
grid={alertStore.data.grids[0]}
outerPadding={0}
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
);
const toggles = container.querySelectorAll("span.cursor-pointer");
fireEvent.click(toggles[1], { altKey: true });
const collapseEvent = dispatchSpy.mock.calls
.map((call) => call[0] as Event)
.find((evt) => evt.type === "alertGridCollapse") as CustomEvent;
expect(collapseEvent).toBeDefined();
expect(collapseEvent.detail).toBe(false);
dispatchSpy.mockRestore();
});
it("reacts to alertGridCollapse events", () => {
jest.useFakeTimers();
MockGroupList(3, 1);
const grid = {
...alertStore.data.grids[0],
labelName: "foo",
labelValue: "bar",
};
alertStore.data.setGrids([grid]);
const { container } = render(
<ThemeContext.Provider value={MockThemeContext}>
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
gridSizesConfig={GridSizesConfig(420)}
groupWidth={420}
grid={grid}
outerPadding={0}
paddingTop={0}
zIndex={101}
/>
</ThemeContext.Provider>,
);
expect(
container.querySelectorAll("div.components-grid-alertgrid-alertgroup"),
).toHaveLength(3);
act(() => {
window.dispatchEvent(
new CustomEvent("alertGridCollapse", { detail: false }),
);
});
act(() => {
jest.runOnlyPendingTimers();
});
expect(
container.querySelectorAll("div.components-grid-alertgrid-alertgroup"),
).toHaveLength(0);
});
it("renders filter badge for grids with a value", () => {
MockGroupList(1, 1);
const grid = MockGrid();
@@ -6,12 +6,16 @@ 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 () => clearTimeout(timer);
return () => clearReloadTimer(timer);
}, [reloadAfter]);
return (
@@ -8,13 +8,17 @@ 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 () => clearTimeout(timer);
return () => clearUpgradeTimer(timer);
}, [reloadAfter]);
return (
+7 -5
View File
@@ -44,16 +44,18 @@ const InlineEdit: FC<{
};
const onInput = (event: ChangeEvent<HTMLInputElement>) => {
setEditedValue(event.target.value.trim());
setEditedValue(event.target.value);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.keyCode === 13) {
if (editedValue) {
onChange(editedValue);
if (event.key === "Enter") {
if (editedValue !== null && editedValue.trim() !== "") {
onChange(editedValue.trim());
} else if (editedValue === "") {
onChange("");
}
doneEditing();
} else if (event.keyCode === 27) {
} else if (event.key === "Escape") {
doneEditing();
}
};
@@ -199,6 +199,32 @@ describe("<FilterInputLabel /> onChange", () => {
expect(alertStore.filters.values).toHaveLength(1);
expect(alertStore.filters.values[0].raw).toBe("foo=newvalue");
});
it("editing filter to empty value removes it from alertStore", () => {
// Verifies that onChange removes filter when edited to empty string (line 24)
const filter1 = createFilter("=", true, true, 1);
const filter2 = NewUnappliedFilter("baz=qux");
alertStore.filters.setFilterValues([filter1, filter2]);
const { container } = render(
<FilterInputLabel
alertStore={alertStore}
filter={alertStore.filters.values[0]}
/>,
);
const editSpan = container.querySelector(
".components-filteredinputlabel-text span",
);
fireEvent.click(editSpan!);
const input = container.querySelector("input");
fireEvent.change(input!, { target: { value: "" } });
fireEvent.keyDown(input!, { keyCode: 13 });
expect(alertStore.filters.values).toHaveLength(1);
expect(alertStore.filters.values[0].raw).toBe("baz=qux");
});
});
describe("<FilterInputLabel /> render", () => {
@@ -74,8 +74,7 @@ const FilteringCounterBadge: FC<{
<span
ref={(node) => {
ref(node);
(nodeRef as React.MutableRefObject<HTMLElement | null>).current =
node;
nodeRef.current = node;
}}
className={
themed
+1 -1
View File
@@ -37,7 +37,7 @@ const MainModal: FC<{
className="nav-link cursor-pointer"
onClick={toggle}
>
<FontAwesomeIcon icon={faSlidersH} fixedWidth />
<FontAwesomeIcon icon={faSlidersH} className="fa-fw" />
</span>
</TooltipWrapper>
</li>
@@ -38,8 +38,7 @@ const SilenceIDCopyButton: FC<{
<span
ref={(node) => {
ref(node);
(nodeRef as React.MutableRefObject<HTMLElement | null>).current =
node;
nodeRef.current = node;
}}
className="badge bg-secondary px-1 me-1 components-label cursor-pointer"
onClick={() => {
@@ -93,9 +92,8 @@ const SilenceDetails: FC<{
<div>
<span className="badge px-1 me-1 components-label silence-detail">
<FontAwesomeIcon
className="text-muted me-1"
className="text-muted me-1 fa-fw"
icon={faCalendarCheck}
fixedWidth
/>
Started <DateFromNow timestamp={silence.startsAt} />
</span>
@@ -103,9 +101,8 @@ const SilenceDetails: FC<{
className={`badge ${expiresClass} px-1 me-1 components-label silence-detail`}
>
<FontAwesomeIcon
className="text-muted me-1"
className="text-muted me-1 fa-fw"
icon={faCalendarTimes}
fixedWidth
/>
{expiresLabel} <DateFromNow timestamp={silence.endsAt} />
</span>
@@ -113,9 +110,8 @@ const SilenceDetails: FC<{
<div className="my-1 d-flex flex-row">
<span className="badge px-1 me-1 components-label silence-detail flex-grow-0 flex-shrink-0">
<FontAwesomeIcon
className="text-muted me-1"
className="text-muted me-1 fa-fw"
icon={faFingerprint}
fixedWidth
/>
ID:
</span>
@@ -127,9 +123,8 @@ const SilenceDetails: FC<{
<div className="my-1">
<span className="badge px-1 me-1 components-label silence-detail">
<FontAwesomeIcon
className="text-muted me-1"
className="text-muted me-1 fa-fw"
icon={faHome}
fixedWidth
/>
View in Alertmanager:
</span>
@@ -145,9 +140,8 @@ const SilenceDetails: FC<{
<div className="flex-shrink-0 flex-grow-0">
<span className="badge px-1 me-1 components-label silence-detail">
<FontAwesomeIcon
className="text-muted me-1"
className="text-muted me-1 fa-fw"
icon={faFilter}
fixedWidth
/>
Matchers:
</span>
@@ -4,7 +4,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { History } from "./History";
import { History, HistoryMenu, ReduceFilter } from "./History";
let alertStore: AlertStore;
let settingsStore: Settings;
@@ -261,7 +261,6 @@ describe("<HistoryMenu />", () => {
});
it("clicking on 'Clear history' button triggers clear action", async () => {
// Verifies that clicking Clear history button calls the onClear callback
const promise = Promise.resolve();
const { container } = renderHistory();
@@ -272,24 +271,54 @@ describe("<HistoryMenu />", () => {
const toggle = container.querySelector("button.cursor-pointer");
fireEvent.click(toggle!);
// Verify history has items before clearing
const historyItemsBefore = document.body.querySelectorAll(
".components-navbar-historymenu-labels",
);
expect(historyItemsBefore.length).toBeGreaterThan(0);
// Click clear history button - this calls history.setFilters([])
const clearButton = screen.getByText("Clear history");
fireEvent.click(clearButton);
act(() => {
jest.runOnlyPendingTimers();
});
// Menu should close after clicking clear (afterClick is called)
expect(
container.querySelector("div.dropdown-menu"),
).not.toBeInTheDocument();
await act(() => promise);
});
it("HistoryMenu renders correctly with null coordinates", () => {
render(
<HistoryMenu
x={null}
y={null}
floating={null}
strategy="absolute"
maxHeight={null}
filters={[]}
alertStore={alertStore}
settingsStore={settingsStore}
afterClick={jest.fn()}
onClear={jest.fn()}
/>,
);
const menu = document.body.querySelector(
".components-navbar-historymenu",
) as HTMLElement;
expect(menu.style.top).toBe("");
expect(menu.style.left).toBe("");
});
it("ReduceFilter returns a reduced filter object", () => {
const filter = AppliedFilter("foo", "=", "bar");
const reduced = ReduceFilter(filter);
expect(reduced).toEqual({
raw: "foo=bar",
name: "foo",
matcher: "=",
value: "bar",
});
});
});
+17
View File
@@ -6,6 +6,7 @@ import fetchMock from "@fetch-mock/jest";
import { useIdleTimer } from "react-idle-timer";
import { IsMobile } from "Common/Device";
import { MockThemeContext } from "__fixtures__/Theme";
import { EmptyAPIResponse } from "__fixtures__/Fetch";
import { AlertStore } from "Stores/AlertStore";
@@ -15,6 +16,7 @@ import { ThemeContext } from "Components/Theme";
import NavBar from ".";
jest.mock("react-idle-timer");
jest.mock("Common/Device");
let alertStore: AlertStore;
let settingsStore: Settings;
@@ -245,4 +247,19 @@ describe("<NavBar />", () => {
.getPropertyValue("padding-top"),
).toBe("44px");
});
it("uses mobile idle timeout when IsMobile returns true", () => {
// Verifies that MobileIdleTimeout is used when IsMobile() returns true (line 61)
(IsMobile as jest.Mock).mockReturnValue(true);
renderNavbar();
expect(useIdleTimer).toHaveBeenCalledWith(
expect.objectContaining({
timeout: expect.any(Number),
}),
);
const callArgs = (useIdleTimer as jest.Mock).mock.calls[0][0];
expect(callArgs.timeout).toBeGreaterThan(0);
});
});
+1 -1
View File
@@ -114,7 +114,7 @@ const NavBar: FC<{
ref={(el) => {
observe(el as HTMLElement);
ref.current = el as HTMLElement;
(navRef as React.MutableRefObject<HTMLElement | null>).current = el;
navRef.current = el;
}}
className={`navbar navbar-expand navbar-dark p-1 bg-primary-transparent d-flex ${
fixedTop ? "fixed-top" : "w-100"
+1 -2
View File
@@ -37,8 +37,7 @@ const OverviewModal: FC<{
<div
ref={(node) => {
ref(node);
(nodeRef as React.MutableRefObject<HTMLElement | null>).current =
node;
nodeRef.current = node;
}}
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
isVisible ? "border-info" : ""
@@ -63,8 +63,8 @@ export const SelectableSilence: FC<{
};
const SilenceDeleteMenu: FC<{
x: number | null;
y: number | null;
x: number;
y: number;
floating: Ref<HTMLDivElement> | null;
strategy: CSSProperties["position"];
maxHeight: number | null;
@@ -76,8 +76,8 @@ const SilenceDeleteMenu: FC<{
ref={floating}
style={{
position: strategy,
top: y ?? "",
left: x ?? "",
top: y,
left: x,
maxHeight: maxHeight ?? "",
}}
>
@@ -1077,4 +1077,91 @@ describe("<SilenceDelete />", () => {
await act(() => promise);
});
it("closes modal when close button is clicked", async () => {
// Verifies the onHide callback is invoked when the modal close button is clicked
const promise = Promise.resolve();
const newSilence = (id: string): APISilenceT => {
const s = MockSilence();
s.id = id;
return s;
};
useFetchGetMock.fetch.setMockedData({
response: [
{
cluster: cluster,
alertCount: 1,
silence: newSilence("1"),
isExpired: false,
},
],
error: null,
isLoading: false,
isRetrying: false,
retryCount: 0,
get: jest.fn(),
cancelGet: jest.fn(),
});
const { container } = renderBrowser();
const checkboxes = container.querySelectorAll("input.form-check-input");
fireEvent.click(checkboxes[1]);
const deleteButtons = container.querySelectorAll("button.btn-danger");
const trashBtn = deleteButtons[0];
fireEvent.click(trashBtn);
let closeBtn = document.querySelector(".btn-close") as HTMLElement;
expect(closeBtn).toBeInTheDocument();
fireEvent.click(closeBtn);
act(() => {
jest.advanceTimersByTime(300);
});
closeBtn = document.querySelector(".btn-close") as HTMLElement;
expect(closeBtn).not.toBeInTheDocument();
await act(() => promise);
});
it("renders dropdown menu", async () => {
// Verifies the SilenceDeleteMenu renders when dropdown is opened
const newSilence = (id: string): APISilenceT => {
const s = MockSilence();
s.id = id;
return s;
};
useFetchGetMock.fetch.setMockedData({
response: [
{
cluster: cluster,
alertCount: 1,
silence: newSilence("1"),
isExpired: false,
},
],
error: null,
isLoading: false,
isRetrying: false,
retryCount: 0,
get: jest.fn(),
cancelGet: jest.fn(),
});
const { container } = renderBrowser();
const checkboxes = container.querySelectorAll("input.form-check-input");
fireEvent.click(checkboxes[1]);
const dropdownToggles = container.querySelectorAll(".btn.dropdown-toggle");
fireEvent.click(dropdownToggles[dropdownToggles.length - 1]);
const dropdownMenu = container.querySelector(".dropdown-menu");
expect(dropdownMenu).toBeInTheDocument();
});
});
@@ -71,7 +71,7 @@ const TabContentStart: FC<{
disabled={isSameMonth(today, month)}
onClick={() => setMonth(today)}
>
<FontAwesomeIcon icon={faCalendarDay} className="me-1" fixedWidth />
<FontAwesomeIcon icon={faCalendarDay} className="me-1 fa-fw" />
Today
</button>
</div>
@@ -130,7 +130,7 @@ const TabContentEnd: FC<{ silenceFormStore: SilenceFormStore }> = observer(
disabled={isSameMonth(today, month)}
onClick={() => setMonth(today)}
>
<FontAwesomeIcon icon={faCalendarDay} className="me-1" fixedWidth />
<FontAwesomeIcon icon={faCalendarDay} className="me-1 fa-fw" />
Today
</button>
</div>
@@ -1,4 +1,4 @@
import { FC, useEffect, useState, MouseEvent, FormEvent } from "react";
import { FC, useEffect, useState, MouseEvent, SyntheticEvent } from "react";
import { observer } from "mobx-react-lite";
@@ -177,7 +177,7 @@ const SilenceForm: FC<{
silenceFormStore.data.setComment(comment);
};
const handleSubmit = (event: FormEvent) => {
const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const rbc: { [label: string]: ClusterRequestT } = {};
@@ -221,7 +221,7 @@ const SilenceForm: FC<{
className="btn btn-sm btn-outline-secondary"
onClick={addMore}
>
<FontAwesomeIcon icon={faPlus} fixedWidth />
<FontAwesomeIcon icon={faPlus} className="fa-fw" />
</button>
</TooltipWrapper>
</div>
@@ -88,7 +88,7 @@ const SilenceMatch: FC<{
className="btn btn-sm btn-outline-danger"
onClick={onDelete}
>
<FontAwesomeIcon icon={faTrash} fixedWidth />
<FontAwesomeIcon icon={faTrash} className="fa-fw" />
</button>
</TooltipWrapper>
) : null}
+1 -1
View File
@@ -37,7 +37,7 @@ const SilenceModal: FC<{
className="nav-link cursor-pointer"
onClick={silenceFormStore.toggle.toggle}
>
<FontAwesomeIcon icon={faBellSlash} fixedWidth />
<FontAwesomeIcon icon={faBellSlash} className="fa-fw" />
</span>
</TooltipWrapper>
</li>
+1 -1
View File
@@ -40,7 +40,7 @@ const AppToasts: FC<{
className="nav-link cursor-pointer"
onClick={show}
>
<FontAwesomeIcon icon={faInfoCircle} fixedWidth />
<FontAwesomeIcon icon={faInfoCircle} className="fa-fw" />
</span>
</TooltipWrapper>
</li>
@@ -61,4 +61,17 @@ describe("<UpgradeToastMessage />", () => {
fireEvent.animationEnd(progressbar!);
expect(alertStore.info.upgradeNeeded).toBe(true);
});
it("animation end while paused still flag upgrade", () => {
// Scenario: user paused auto-reload but animationEnd still fires to set upgrade needed
const { container } = render(
<UpgradeToastMessage alertStore={alertStore} />,
);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(alertStore.info.upgradeNeeded).toBe(false);
const progressbar = container.querySelector("div.progress-bar");
fireEvent.animationEnd(progressbar!);
expect(alertStore.info.upgradeNeeded).toBe(true);
});
});
@@ -2,7 +2,7 @@ import { act } from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { TooltipWrapper } from ".";
import { TooltipWrapper, TooltipContent } from ".";
describe("TooltipWrapper", () => {
beforeEach(() => {
@@ -118,4 +118,19 @@ describe("TooltipWrapper", () => {
jest.runAllTimers();
});
});
it("TooltipContent renders with empty coordinates when x and y are null", () => {
const { container } = render(
<TooltipContent
title="my title"
setFloating={jest.fn()}
strategy="absolute"
x={null}
y={null}
/>,
);
const tooltip = container.querySelector(".tooltip") as HTMLElement;
expect(tooltip.style.top).toBe("");
expect(tooltip.style.left).toBe("");
});
});
+2 -3
View File
@@ -29,8 +29,7 @@ const TooltipContent: FC<{
className="tooltip tooltip-inner"
ref={(el) => {
setFloating(el);
(nodeRef as React.MutableRefObject<HTMLDivElement | null>).current =
el;
nodeRef.current = el;
}}
style={{
position: strategy,
@@ -116,4 +115,4 @@ const TooltipWrapper: FC<{
);
};
export { TooltipWrapper };
export { TooltipWrapper, TooltipContent };
+46 -1
View File
@@ -70,6 +70,51 @@ describe("<ErrorBoundary />", () => {
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText(/auto refresh in 58s/)).toBeInTheDocument();
});
it("reloadApp decrements countdown when more than one second is left", () => {
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 });
});
it("reloadApp does not decrement when countdown reaches 1 or less", () => {
// Verifies that reloadApp calls window.location.reload when reloadSeconds <= 1 (line 65)
const boundary = new ErrorBoundary({ children: <span /> });
const setStateSpy = jest.spyOn(boundary, "setState");
(boundary as any).state = { cachedError: null, reloadSeconds: 1 };
boundary.reloadApp();
expect(setStateSpy).not.toHaveBeenCalled();
});
it("componentDidCatch does not set error if already cached", () => {
// Verifies that componentDidCatch skips setState when error is already cached (line 75)
const boundary = new ErrorBoundary({ children: <span /> });
const setStateSpy = jest.spyOn(boundary, "setState");
const error = new Error("Test error");
(boundary as any).state = { cachedError: error, reloadSeconds: 60 };
boundary.componentDidCatch(error, { componentStack: "" });
expect(setStateSpy).not.toHaveBeenCalled();
});
it("componentDidCatch does not set timer if already set", () => {
// Verifies that componentDidCatch skips setInterval when timer is already set (line 80)
const boundary = new ErrorBoundary({ children: <span /> });
const setIntervalSpy = jest.spyOn(global, "setInterval");
const error = new Error("Test error");
(boundary as any).timer = 123;
boundary.componentDidCatch(error, { componentStack: "" });
expect(setIntervalSpy).not.toHaveBeenCalled();
setIntervalSpy.mockRestore();
});
});
+163 -1
View File
@@ -4,7 +4,7 @@ import { renderHook, render, waitFor } from "@testing-library/react";
import fetchMock from "@fetch-mock/jest";
import { useFetchAny, UpstreamT } from "./useFetchAny";
import { useFetchAny, UpstreamT, FetchFunctionT } from "./useFetchAny";
describe("useFetchAny", () => {
beforeEach(() => {
@@ -270,6 +270,65 @@ describe("useFetchAny", () => {
await fetchMock.callHistory.flush(true);
});
it("skips updating state when unmounted during body parsing", async () => {
let resolveJson: (value: unknown) => void = () => {};
const jsonSpy = jest.fn(
() =>
new Promise((resolve) => {
resolveJson = resolve;
}),
);
const mockResponse = {
headers: new Headers({ "content-type": "application/json" }),
json: jsonSpy,
} as unknown as Response;
const fetcher = jest.fn() as jest.MockedFunction<FetchFunctionT>;
fetcher.mockResolvedValue(mockResponse);
const upstreams = [{ uri: "http://localhost/cancel/json", options: {} }];
const { result, unmount } = renderHook(() =>
useFetchAny<string>(upstreams, { fetcher }),
);
await waitFor(() => expect(jsonSpy.mock.calls.length).toBe(1));
unmount();
await act(async () => {
resolveJson({ data: "late" });
});
expect(fetcher.mock.calls.length).toBe(1);
expect(result.current.response).toBeNull();
expect(result.current.error).toBeNull();
});
it("skips error updates when unmounted before rejection settles", async () => {
let rejectFetch: (reason?: unknown) => void = () => {};
const fetcher = jest.fn() as jest.MockedFunction<FetchFunctionT>;
fetcher.mockImplementation(
() =>
new Promise<Response>((_, reject) => {
rejectFetch = reject;
}),
);
const upstreams = [{ uri: "http://localhost/cancel/error", options: {} }];
const { result, unmount } = renderHook(() =>
useFetchAny<string>(upstreams, { fetcher }),
);
unmount();
await act(async () => {
rejectFetch(new Error("late boom"));
});
expect(fetcher.mock.calls.length).toBe(1);
expect(result.current.error).toBeNull();
});
it("doesn't retry on success", async () => {
const upstreams = [
{ uri: "http://localhost/ok", options: {} },
@@ -348,4 +407,107 @@ describe("useFetchAny", () => {
expect(result.current.inProgress).toBe(false);
expect(result.current.responseURI).toBe(null);
});
it("parses JSON from the final upstream even after failures", async () => {
const upstreams = [
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/ok/json", options: {} },
];
const { result } = renderHook(() => useFetchAny(upstreams));
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(fetchMock.callHistory.calls()).toHaveLength(2);
expect(result.current.response).toMatchObject({ status: "ok" });
expect(result.current.responseURI).toBe("http://localhost/ok/json");
expect(result.current.error).toBe(null);
});
it("propagates error from custom fetcher on last upstream", async () => {
const failingFetcher = jest
.fn()
.mockResolvedValueOnce(new Response("", { status: 500 }))
.mockRejectedValueOnce(new Error("boom"));
const upstreams = [
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/error", options: {} },
];
const { result } = renderHook(() =>
useFetchAny<string>(upstreams, { fetcher: failingFetcher }),
);
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(failingFetcher.mock.calls.length).toBe(2);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("boom");
expect(result.current.responseURI).toBe(null);
});
it("reset clears previous response state without triggering another fetch", async () => {
// Scenario: consumer wants to drop stale successful data but keeps the same upstream list
const upstreams = [{ uri: "http://localhost/ok", options: {} }];
const { result } = renderHook(() => useFetchAny<string>(upstreams));
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(result.current.response).toBe("body ok");
expect(fetchMock.callHistory.calls()).toHaveLength(1);
act(() => {
result.current.reset();
});
expect(result.current.response).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.responseURI).toBe(null);
expect(result.current.inProgress).toBe(false);
expect(fetchMock.callHistory.calls()).toHaveLength(1);
});
it("reset restarts fetching when previous failures advanced through upstreams", async () => {
// Scenario: previous attempts exhausted the list so the hook should return to the first URI after reset
const upstreams = [
{ uri: "http://localhost/500", options: {} },
{ uri: "http://localhost/error", options: {} },
];
const { result } = renderHook(() => useFetchAny<string>(upstreams));
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(fetchMock.callHistory.calls()).toHaveLength(2);
act(() => {
result.current.reset();
});
await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(4));
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(fetchMock.callHistory.calls()[2]?.url).toBe("http://localhost/500");
expect(fetchMock.callHistory.calls()[3]?.url).toBe(
"http://localhost/error",
);
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("failed to fetch");
expect(result.current.responseURI).toBe(null);
});
it("sets fallback message when thrown value is not Error", async () => {
// Scenario: fetch rejects with a plain object so the hook must surface the fallback error text
fetchMock.route("http://localhost/non-error", {
throws: { foo: "bar" } as unknown as Error,
});
const upstreams = [{ uri: "http://localhost/non-error", options: {} }];
const { result } = renderHook(() => useFetchAny<string>(upstreams));
await waitFor(() => expect(result.current.inProgress).toBe(false));
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("unknown error: [object Object]");
expect(result.current.responseURI).toBe(null);
});
});
+41
View File
@@ -135,6 +135,23 @@ describe("useFetchDelete", () => {
expect(result.current.isDeleting).toBe(false);
});
it("sets fallback message when thrown value is not Error", async () => {
// Scenario: fetch rejects with a plain value so the hook must use fallback message formatting
fetchMock.route("http://localhost/non-error", {
throws: "boom" as unknown as Error,
});
const { result } = renderHook(() =>
useFetchDelete("http://localhost/non-error", EmptyOptions),
);
await waitFor(() => expect(result.current.isDeleting).toBe(false));
expect(result.current.response).toBe(null);
expect(result.current.error).toBe("unknown error: boom");
expect(result.current.isDeleting).toBe(false);
});
it("doesn't update response after cleanup", async () => {
fetchMock.route(
"http://localhost/slow/ok",
@@ -217,4 +234,28 @@ describe("useFetchDelete", () => {
await fetchMock.callHistory.flush(true);
});
it("ignores rejection if fetch fails after cleanup", async () => {
let rejectFn: (reason?: unknown) => void = () => {};
fetchMock.route(
"http://localhost/slow/reject",
new Promise((_resolve, reject) => {
rejectFn = reject;
}),
);
const { unmount } = renderHook(() =>
useFetchDelete("http://localhost/slow/reject", EmptyOptions),
);
await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));
unmount();
await act(async () => {
rejectFn(new Error("boom"));
});
await fetchMock.callHistory.flush(true);
});
});
+76
View File
@@ -272,6 +272,82 @@ describe("useFetchGet", () => {
expect(result.current.isRetrying).toBe(false);
});
it("stops retrying when cancelGet is called before a rejection settles", async () => {
let rejectFetch: (reason?: unknown) => void = () => {};
const fetcher = jest.fn(
() =>
new Promise<Response>((_, reject) => {
rejectFetch = reject;
}),
);
const { result } = renderHook(() =>
useFetchGet<string>("http://localhost/cancel/retry", {
fetcher,
autorun: false,
}),
);
act(() => {
result.current.get();
});
await waitFor(() => expect(fetcher.mock.calls.length).toBe(1));
act(() => {
result.current.cancelGet();
});
await act(async () => {
rejectFetch(new Error("boom"));
});
expect(fetcher.mock.calls.length).toBe(1);
expect(result.current.isRetrying).toBe(false);
expect(result.current.retryCount).toBe(0);
expect(result.current.error).toBeNull();
});
it("skips response processing when cancelled before body parsing", async () => {
let resolveFetch: (value: Response) => void = () => {};
const fetcher = jest.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const textSpy = jest.fn(async () => "late body");
const response = {
ok: true,
headers: new Headers({ "content-type": "text/plain" }),
text: textSpy,
} as unknown as Response;
const { result } = renderHook(() =>
useFetchGet<string>("http://localhost/cancel/success", {
fetcher,
autorun: false,
}),
);
act(() => {
result.current.get();
});
await waitFor(() => expect(fetcher.mock.calls.length).toBe(1));
act(() => {
result.current.cancelGet();
});
await act(async () => {
resolveFetch(response);
});
expect(textSpy.mock.calls.length).toBe(0);
expect(result.current.response).toBeNull();
expect(result.current.error).toBeNull();
});
it("doesn't update response on 200 response after cleanup", async () => {
fetchMock.route("http://localhost/slow/ok", {
delay: 1000,
+12 -6
View File
@@ -30,8 +30,10 @@ describe("useOnClickOutside", () => {
render(<Component enabled />);
expect(screen.getByText("Open")).toBeInTheDocument();
const clickEvent = document.createEvent("MouseEvents");
clickEvent.initEvent("mousedown", true, true);
const clickEvent = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
});
act(() => {
document.dispatchEvent(clickEvent);
});
@@ -43,8 +45,10 @@ describe("useOnClickOutside", () => {
render(<Component enabled />);
expect(screen.getByText("Open")).toBeInTheDocument();
const clickEvent = document.createEvent("MouseEvents");
clickEvent.initEvent("mousedown", true, true);
const clickEvent = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
});
act(() => {
document.dispatchEvent(clickEvent);
});
@@ -66,8 +70,10 @@ describe("useOnClickOutside", () => {
const { rerender } = render(<Component enabled={false} />);
expect(screen.getByText("Open")).toBeInTheDocument();
const clickEvent = document.createEvent("MouseEvents");
clickEvent.initEvent("mousedown", true, true);
const clickEvent = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
});
act(() => {
document.dispatchEvent(clickEvent);
});