diff --git a/go.mod b/go.mod index 36c416621..6e76ad091 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7859c655f..4a68b661a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 89c1063b0..eca5aba43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) } diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 000000000..d422678a5 --- /dev/null +++ b/ui/.eslintrc.cjs @@ -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; diff --git a/ui/.eslintrc.yaml b/ui/.eslintrc.yaml deleted file mode 100644 index d1fefc6a9..000000000 --- a/ui/.eslintrc.yaml +++ /dev/null @@ -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 diff --git a/ui/src/AppBoot.test.tsx b/ui/src/AppBoot.test.tsx index f2e178bef..296feaddc 100644 --- a/ui/src/AppBoot.test.tsx +++ b/ui/src/AppBoot.test.tsx @@ -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(); + }); }); diff --git a/ui/src/Components/AlertAck/index.tsx b/ui/src/Components/AlertAck/index.tsx index 4417e0ebd..e7bc6307c 100644 --- a/ui/src/Components/AlertAck/index.tsx +++ b/ui/src/Components/AlertAck/index.tsx @@ -172,13 +172,13 @@ const AlertAck: FC<{ }} > {!isAcking && error ? ( - + ) : !isAcking && response ? ( - + ) : isAcking ? ( - + ) : ( - + )} diff --git a/ui/src/Components/AlertHistory/index.test.tsx b/ui/src/Components/AlertHistory/index.test.tsx index 203d32f27..a4193f4ec 100644 --- a/ui/src/Components/AlertHistory/index.test.tsx +++ b/ui/src/Components/AlertHistory/index.test.tsx @@ -586,8 +586,8 @@ describe("", () => { 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(); diff --git a/ui/src/Components/Fetcher/index.tsx b/ui/src/Components/Fetcher/index.tsx index c15eb9a69..b95f3f22f 100644 --- a/ui/src/Components/Fetcher/index.tsx +++ b/ui/src/Components/Fetcher/index.tsx @@ -31,9 +31,8 @@ const PauseButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => { > @@ -56,9 +55,8 @@ const PlayButton: FC<{ alertStore: AlertStore }> = ({ alertStore }) => { > diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx index e0adf8ae8..c180832d5 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx @@ -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("", () => { JSON.stringify(alertToJSON(group, alert)), ); }); + + it("supports floating callback refs", () => { + const floatingRef = jest.fn(); + const forwardedRef = createRef(); + + render( + , + ); + + expect(floatingRef).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + expect(forwardedRef.current).toBeInstanceOf(HTMLDivElement); + }); + + it("supports floating object refs", () => { + const floatingObjectRef = createRef(); + const forwardedRef = createRef(); + + render( + , + ); + + expect(floatingObjectRef.current).toBeInstanceOf(HTMLDivElement); + expect(forwardedRef.current).toBeInstanceOf(HTMLDivElement); + }); + + it("invokes forwarded callback refs", () => { + const floatingObjectRef = createRef(); + const forwardedRef = jest.fn(); + + render( + , + ); + + 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(); + }); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx index 443ec02d0..29b96ed4a 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx @@ -64,8 +64,8 @@ const onSilenceClick = ( }; interface MenuContentProps { - x: number | null; - y: number | null; + x: number; + y: number; floating: Ref | null; strategy: CSSProperties["position"]; group: APIAlertGroupT; @@ -104,25 +104,21 @@ const MenuContent = forwardRef(
{ - // Handle both the floating ref and the forwarded ref if (typeof floating === "function") { floating(node); } else if (floating) { - ( - floating as React.MutableRefObject - ).current = node; + floating.current = node; } if (typeof ref === "function") { ref(node); } else if (ref) { - (ref as React.MutableRefObject).current = - node; + ref.current = node; } }} style={{ position: strategy, - top: y ?? "", - left: x ?? "", + top: y, + left: x, }} >
Alert source links:
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx index 34e65a2f5..a86ff35f9 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.tsx @@ -65,9 +65,7 @@ const RenderNonLinkAnnotation: FC<{ { ref(node); - ( - nodeRef as React.MutableRefObject - ).current = node; + nodeRef.current = node; }} dangerouslySetInnerHTML={{ __html: value }} > @@ -75,9 +73,7 @@ const RenderNonLinkAnnotation: FC<{ { ref(node); - ( - nodeRef as React.MutableRefObject - ).current = node; + nodeRef.current = node; }} > {value} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx index 3226ce9e8..0167ccba5 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx @@ -50,8 +50,8 @@ const onSilenceClick = ( }; const MenuContent: FC<{ - x: number | null; - y: number | null; + x: number; + y: number; floating: Ref | 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 ? ( diff --git a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx index 62ac5ce3d..7c3ce2c51 100644 --- a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.test.tsx @@ -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("", () => { 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); + }); }); diff --git a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx index 0ff654b75..690d51b22 100644 --- a/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx +++ b/ui/src/Components/Grid/AlertGrid/GridLabelSelect.tsx @@ -89,8 +89,8 @@ const GridLabelNameSelect: FC<{ }; const Dropdown: FC<{ - x: number | null; - y: number | null; + x: number; + y: number; floating: Ref | 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, }} > ", () => { ).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( + + + , + ); + + 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( + + + , + ); + + 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(); diff --git a/ui/src/Components/Grid/ReloadNeeded/index.tsx b/ui/src/Components/Grid/ReloadNeeded/index.tsx index c376f2a6a..79c2bb4ea 100644 --- a/ui/src/Components/Grid/ReloadNeeded/index.tsx +++ b/ui/src/Components/Grid/ReloadNeeded/index.tsx @@ -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 ( diff --git a/ui/src/Components/Grid/UpgradeNeeded/index.tsx b/ui/src/Components/Grid/UpgradeNeeded/index.tsx index ea9f75cc9..514d20a8c 100644 --- a/ui/src/Components/Grid/UpgradeNeeded/index.tsx +++ b/ui/src/Components/Grid/UpgradeNeeded/index.tsx @@ -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 ( diff --git a/ui/src/Components/InlineEdit/index.tsx b/ui/src/Components/InlineEdit/index.tsx index ef1ad497f..c958a497f 100644 --- a/ui/src/Components/InlineEdit/index.tsx +++ b/ui/src/Components/InlineEdit/index.tsx @@ -44,16 +44,18 @@ const InlineEdit: FC<{ }; const onInput = (event: ChangeEvent) => { - 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(); } }; diff --git a/ui/src/Components/Labels/FilterInputLabel/index.test.tsx b/ui/src/Components/Labels/FilterInputLabel/index.test.tsx index b32a1a6b8..d094a2b53 100644 --- a/ui/src/Components/Labels/FilterInputLabel/index.test.tsx +++ b/ui/src/Components/Labels/FilterInputLabel/index.test.tsx @@ -199,6 +199,32 @@ describe(" 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( + , + ); + + 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(" render", () => { diff --git a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx index 44ee9e09b..04393cb6d 100644 --- a/ui/src/Components/Labels/FilteringCounterBadge/index.tsx +++ b/ui/src/Components/Labels/FilteringCounterBadge/index.tsx @@ -74,8 +74,7 @@ const FilteringCounterBadge: FC<{ { ref(node); - (nodeRef as React.MutableRefObject).current = - node; + nodeRef.current = node; }} className={ themed diff --git a/ui/src/Components/MainModal/index.tsx b/ui/src/Components/MainModal/index.tsx index fb484d0a5..6b8f3d9e8 100644 --- a/ui/src/Components/MainModal/index.tsx +++ b/ui/src/Components/MainModal/index.tsx @@ -37,7 +37,7 @@ const MainModal: FC<{ className="nav-link cursor-pointer" onClick={toggle} > - + diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.tsx index d6895388e..8c372cf5c 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.tsx @@ -38,8 +38,7 @@ const SilenceIDCopyButton: FC<{ { ref(node); - (nodeRef as React.MutableRefObject).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<{
Started @@ -103,9 +101,8 @@ const SilenceDetails: FC<{ className={`badge ${expiresClass} px-1 me-1 components-label silence-detail`} > {expiresLabel} @@ -113,9 +110,8 @@ const SilenceDetails: FC<{
ID: @@ -127,9 +123,8 @@ const SilenceDetails: FC<{
View in Alertmanager: @@ -145,9 +140,8 @@ const SilenceDetails: FC<{
Matchers: diff --git a/ui/src/Components/NavBar/FilterInput/History.test.tsx b/ui/src/Components/NavBar/FilterInput/History.test.tsx index 6a11a4173..357bf9783 100644 --- a/ui/src/Components/NavBar/FilterInput/History.test.tsx +++ b/ui/src/Components/NavBar/FilterInput/History.test.tsx @@ -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("", () => { }); 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("", () => { 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( + , + ); + 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", + }); + }); }); diff --git a/ui/src/Components/NavBar/index.test.tsx b/ui/src/Components/NavBar/index.test.tsx index 44f3c47ba..0362ddb02 100644 --- a/ui/src/Components/NavBar/index.test.tsx +++ b/ui/src/Components/NavBar/index.test.tsx @@ -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("", () => { .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); + }); }); diff --git a/ui/src/Components/NavBar/index.tsx b/ui/src/Components/NavBar/index.tsx index cb219e0d9..3286a35fb 100644 --- a/ui/src/Components/NavBar/index.tsx +++ b/ui/src/Components/NavBar/index.tsx @@ -114,7 +114,7 @@ const NavBar: FC<{ ref={(el) => { observe(el as HTMLElement); ref.current = el as HTMLElement; - (navRef as React.MutableRefObject).current = el; + navRef.current = el; }} className={`navbar navbar-expand navbar-dark p-1 bg-primary-transparent d-flex ${ fixedTop ? "fixed-top" : "w-100" diff --git a/ui/src/Components/OverviewModal/index.tsx b/ui/src/Components/OverviewModal/index.tsx index fefcac603..a22f5713a 100644 --- a/ui/src/Components/OverviewModal/index.tsx +++ b/ui/src/Components/OverviewModal/index.tsx @@ -37,8 +37,7 @@ const OverviewModal: FC<{
{ ref(node); - (nodeRef as React.MutableRefObject).current = - node; + nodeRef.current = node; }} className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${ isVisible ? "border-info" : "" diff --git a/ui/src/Components/SilenceModal/Browser/MassDelete.tsx b/ui/src/Components/SilenceModal/Browser/MassDelete.tsx index 1149bad36..aa2fab1d6 100644 --- a/ui/src/Components/SilenceModal/Browser/MassDelete.tsx +++ b/ui/src/Components/SilenceModal/Browser/MassDelete.tsx @@ -63,8 +63,8 @@ export const SelectableSilence: FC<{ }; const SilenceDeleteMenu: FC<{ - x: number | null; - y: number | null; + x: number; + y: number; floating: Ref | 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 ?? "", }} > diff --git a/ui/src/Components/SilenceModal/Browser/index.test.tsx b/ui/src/Components/SilenceModal/Browser/index.test.tsx index 5d03e41f7..cbe7b8ca1 100644 --- a/ui/src/Components/SilenceModal/Browser/index.test.tsx +++ b/ui/src/Components/SilenceModal/Browser/index.test.tsx @@ -1077,4 +1077,91 @@ describe("", () => { 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(); + }); }); diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.tsx b/ui/src/Components/SilenceModal/DateTimeSelect/index.tsx index cf2f15040..29081c586 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.tsx +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.tsx @@ -71,7 +71,7 @@ const TabContentStart: FC<{ disabled={isSameMonth(today, month)} onClick={() => setMonth(today)} > - + Today
@@ -130,7 +130,7 @@ const TabContentEnd: FC<{ silenceFormStore: SilenceFormStore }> = observer( disabled={isSameMonth(today, month)} onClick={() => setMonth(today)} > - + Today
diff --git a/ui/src/Components/SilenceModal/SilenceForm.tsx b/ui/src/Components/SilenceModal/SilenceForm.tsx index baf1ab2b2..b3cdb15d9 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.tsx +++ b/ui/src/Components/SilenceModal/SilenceForm.tsx @@ -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) => { event.preventDefault(); const rbc: { [label: string]: ClusterRequestT } = {}; @@ -221,7 +221,7 @@ const SilenceForm: FC<{ className="btn btn-sm btn-outline-secondary" onClick={addMore} > - +
diff --git a/ui/src/Components/SilenceModal/SilenceMatch/index.tsx b/ui/src/Components/SilenceModal/SilenceMatch/index.tsx index baddff7f8..d5f8d6f80 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/index.tsx +++ b/ui/src/Components/SilenceModal/SilenceMatch/index.tsx @@ -88,7 +88,7 @@ const SilenceMatch: FC<{ className="btn btn-sm btn-outline-danger" onClick={onDelete} > - + ) : null} diff --git a/ui/src/Components/SilenceModal/index.tsx b/ui/src/Components/SilenceModal/index.tsx index 4fd59001f..866091733 100644 --- a/ui/src/Components/SilenceModal/index.tsx +++ b/ui/src/Components/SilenceModal/index.tsx @@ -37,7 +37,7 @@ const SilenceModal: FC<{ className="nav-link cursor-pointer" onClick={silenceFormStore.toggle.toggle} > - + diff --git a/ui/src/Components/Toast/AppToasts.tsx b/ui/src/Components/Toast/AppToasts.tsx index 2de09a53f..2c06d162c 100644 --- a/ui/src/Components/Toast/AppToasts.tsx +++ b/ui/src/Components/Toast/AppToasts.tsx @@ -40,7 +40,7 @@ const AppToasts: FC<{ className="nav-link cursor-pointer" onClick={show} > - + diff --git a/ui/src/Components/Toast/ToastMessages.test.tsx b/ui/src/Components/Toast/ToastMessages.test.tsx index 9f5b9c448..f827deda0 100644 --- a/ui/src/Components/Toast/ToastMessages.test.tsx +++ b/ui/src/Components/Toast/ToastMessages.test.tsx @@ -61,4 +61,17 @@ describe("", () => { 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( + , + ); + 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); + }); }); diff --git a/ui/src/Components/TooltipWrapper/index.test.tsx b/ui/src/Components/TooltipWrapper/index.test.tsx index acd75217e..ccfc85ecf 100644 --- a/ui/src/Components/TooltipWrapper/index.test.tsx +++ b/ui/src/Components/TooltipWrapper/index.test.tsx @@ -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( + , + ); + const tooltip = container.querySelector(".tooltip") as HTMLElement; + expect(tooltip.style.top).toBe(""); + expect(tooltip.style.left).toBe(""); + }); }); diff --git a/ui/src/Components/TooltipWrapper/index.tsx b/ui/src/Components/TooltipWrapper/index.tsx index 5272beead..477c97d86 100644 --- a/ui/src/Components/TooltipWrapper/index.tsx +++ b/ui/src/Components/TooltipWrapper/index.tsx @@ -29,8 +29,7 @@ const TooltipContent: FC<{ className="tooltip tooltip-inner" ref={(el) => { setFloating(el); - (nodeRef as React.MutableRefObject).current = - el; + nodeRef.current = el; }} style={{ position: strategy, @@ -116,4 +115,4 @@ const TooltipWrapper: FC<{ ); }; -export { TooltipWrapper }; +export { TooltipWrapper, TooltipContent }; diff --git a/ui/src/ErrorBoundary.test.tsx b/ui/src/ErrorBoundary.test.tsx index 6f535417f..f97b5ab5e 100644 --- a/ui/src/ErrorBoundary.test.tsx +++ b/ui/src/ErrorBoundary.test.tsx @@ -70,6 +70,51 @@ describe("", () => { 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: }); + 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: }); + 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: }); + 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: }); + 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(); }); }); diff --git a/ui/src/Hooks/useFetchAny.test.tsx b/ui/src/Hooks/useFetchAny.test.tsx index 8e7707e17..a9ffb62b8 100644 --- a/ui/src/Hooks/useFetchAny.test.tsx +++ b/ui/src/Hooks/useFetchAny.test.tsx @@ -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; + fetcher.mockResolvedValue(mockResponse); + + const upstreams = [{ uri: "http://localhost/cancel/json", options: {} }]; + const { result, unmount } = renderHook(() => + useFetchAny(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; + fetcher.mockImplementation( + () => + new Promise((_, reject) => { + rejectFetch = reject; + }), + ); + + const upstreams = [{ uri: "http://localhost/cancel/error", options: {} }]; + const { result, unmount } = renderHook(() => + useFetchAny(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(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(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(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(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); + }); }); diff --git a/ui/src/Hooks/useFetchDelete.test.tsx b/ui/src/Hooks/useFetchDelete.test.tsx index b25275c24..d670bb679 100644 --- a/ui/src/Hooks/useFetchDelete.test.tsx +++ b/ui/src/Hooks/useFetchDelete.test.tsx @@ -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); + }); }); diff --git a/ui/src/Hooks/useFetchGet.test.tsx b/ui/src/Hooks/useFetchGet.test.tsx index 9ac90607e..0caf977d4 100644 --- a/ui/src/Hooks/useFetchGet.test.tsx +++ b/ui/src/Hooks/useFetchGet.test.tsx @@ -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((_, reject) => { + rejectFetch = reject; + }), + ); + + const { result } = renderHook(() => + useFetchGet("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((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("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, diff --git a/ui/src/Hooks/useOnClickOutside.test.tsx b/ui/src/Hooks/useOnClickOutside.test.tsx index b9d99a131..f7e772113 100644 --- a/ui/src/Hooks/useOnClickOutside.test.tsx +++ b/ui/src/Hooks/useOnClickOutside.test.tsx @@ -30,8 +30,10 @@ describe("useOnClickOutside", () => { render(); 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(); 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(); 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); });