mirror of
https://github.com/prymitive/karma
synced 2026-08-23 11:56:20 +00:00
fix(ui): code fixes
This commit is contained in:
committed by
Łukasz Mierzwa
parent
0e3110c0a2
commit
33ce78b8f0
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user