fix(ui): enable computedRequiresReaction

This commit is contained in:
Lukasz Mierzwa
2026-03-09 12:31:06 +00:00
committed by Łukasz Mierzwa
parent 638531901a
commit c67d2f6e1d
13 changed files with 306 additions and 240 deletions

View File

@@ -18,6 +18,7 @@ import fetchMock from "@fetch-mock/jest";
import { mockMatchMedia } from "__fixtures__/matchMedia";
import { EmptyAPIResponse } from "__fixtures__/Fetch";
import { inReactiveContext } from "__fixtures__/MobX";
import type { UIDefaults, ThemeT } from "Models/UI";
import { SilenceFormStore, NewEmptyMatcher } from "Stores/SilenceFormStore";
import { StringToOption } from "Common/Select";
@@ -198,7 +199,7 @@ describe("<App />", () => {
store.data.setMatchers([m1, m2]);
store.data.setComment("base64");
});
const m = store.data.toBase64;
const m = inReactiveContext(() => store.data.toBase64);
// Use history.pushState instead of setting window.location to avoid jsdom navigation error
window.history.pushState({}, "App", `/?q=bar&m=${m}`);
@@ -242,7 +243,7 @@ describe("<App />", () => {
const store = new SilenceFormStore();
store.data.setMatchers([m1, m2]);
store.data.setComment("base64");
const m = store.data.toBase64;
const m = inReactiveContext(() => store.data.toBase64);
global.window.location = {
href: `http://localhost/?q=bar&m=${m}`,

View File

@@ -1,6 +1,6 @@
import { FC, useEffect, useState } from "react";
import { toJS } from "mobx";
import { action, toJS } from "mobx";
import { observer } from "mobx-react-lite";
import { addSeconds } from "date-fns/addSeconds";
@@ -47,7 +47,7 @@ const AlertAck: FC<{
const { response, error, inProgress, reset } =
useFetchAny<PostResponseT>(upstreams);
const onACK = () => {
const onACK = action(() => {
setIsAcking(true);
let author =
@@ -95,7 +95,7 @@ const AlertAck: FC<{
});
}
setClusters(c);
};
});
useEffect(() => {
if (upstreams.length && !inProgress && (error || response)) {

View File

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

View File

@@ -1,5 +1,7 @@
import { FC, useRef, useState, useCallback, Ref, CSSProperties } from "react";
import { observer } from "mobx-react-lite";
import copy from "copy-to-clipboard";
import { useFloating, shift, offset } from "@floating-ui/react-dom";
@@ -58,88 +60,90 @@ const MenuContent: FC<{
afterClick: () => void;
alertStore: AlertStore;
silenceFormStore: SilenceFormStore;
}> = ({
x,
y,
floating,
strategy,
group,
afterClick,
alertStore,
silenceFormStore,
}) => {
const groupFilters = group.labels.map((label) =>
FormatQuery(label.name, QueryOperators.Equal, label.value),
);
groupFilters.push(
FormatQuery(StaticLabels.Receiver, QueryOperators.Equal, group.receiver),
);
const baseURL = [
window.location.protocol,
"//",
window.location.host,
window.location.pathname,
].join("");
const groupLink = `${baseURL}?${FormatAlertsQ(groupFilters)}`;
}> = observer(
({
x,
y,
floating,
strategy,
group,
afterClick,
alertStore,
silenceFormStore,
}) => {
const groupFilters = group.labels.map((label) =>
FormatQuery(label.name, QueryOperators.Equal, label.value),
);
groupFilters.push(
FormatQuery(StaticLabels.Receiver, QueryOperators.Equal, group.receiver),
);
const baseURL = [
window.location.protocol,
"//",
window.location.host,
window.location.pathname,
].join("");
const groupLink = `${baseURL}?${FormatAlertsQ(groupFilters)}`;
const actions = group.shared.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true);
const actions = group.shared.annotations
.filter((a) => a.isLink === true)
.filter((a) => a.isAction === true);
return (
<FetchPauser alertStore={alertStore}>
<div
className="dropdown-menu d-block shadow m-0"
ref={floating}
style={{
position: strategy,
top: y,
left: x,
}}
>
{actions.length ? (
<>
<h6 className="dropdown-header">Actions:</h6>
{actions.map((action) => (
<MenuLink
key={action.name}
icon={faWrench}
text={action.name}
uri={action.value}
afterClick={afterClick}
/>
))}
<div className="dropdown-divider" />
</>
) : null}
return (
<FetchPauser alertStore={alertStore}>
<div
className="dropdown-item cursor-pointer"
onClick={() => {
copy(groupLink);
afterClick();
className="dropdown-menu d-block shadow m-0"
ref={floating}
style={{
position: strategy,
top: y,
left: x,
}}
>
<FontAwesomeIcon icon={faShareSquare} /> Copy link to this group
</div>
<div
className={`dropdown-item ${
Object.keys(alertStore.data.clustersWithoutReadOnly).length === 0
? "disabled"
: "cursor-pointer"
}`}
onClick={() => {
if (Object.keys(alertStore.data.clustersWithoutReadOnly).length) {
onSilenceClick(alertStore, silenceFormStore, group);
{actions.length ? (
<>
<h6 className="dropdown-header">Actions:</h6>
{actions.map((action) => (
<MenuLink
key={action.name}
icon={faWrench}
text={action.name}
uri={action.value}
afterClick={afterClick}
/>
))}
<div className="dropdown-divider" />
</>
) : null}
<div
className="dropdown-item cursor-pointer"
onClick={() => {
copy(groupLink);
afterClick();
}
}}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this group
}}
>
<FontAwesomeIcon icon={faShareSquare} /> Copy link to this group
</div>
<div
className={`dropdown-item ${
Object.keys(alertStore.data.clustersWithoutReadOnly).length === 0
? "disabled"
: "cursor-pointer"
}`}
onClick={() => {
if (Object.keys(alertStore.data.clustersWithoutReadOnly).length) {
onSilenceClick(alertStore, silenceFormStore, group);
afterClick();
}
}}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this group
</div>
</div>
</div>
</FetchPauser>
);
};
</FetchPauser>
);
},
);
const GroupMenu: FC<{
group: APIAlertGroupT;

View File

@@ -1,5 +1,7 @@
import { FC, useEffect, useState, ReactNode } from "react";
import { observer } from "mobx-react-lite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
@@ -54,7 +56,7 @@ const DeleteResult: FC<{
alertStore: AlertStore;
cluster: string;
id: string;
}> = ({ alertStore, cluster, id }) => {
}> = observer(({ alertStore, cluster, id }) => {
const [currentTime, setCurrentTime] = useState<number>(
Math.floor(Date.now()),
);
@@ -101,7 +103,7 @@ const DeleteResult: FC<{
) : null}
</>
);
};
});
const DeleteSilenceModalContent: FC<{
alertStore: AlertStore;

View File

@@ -4,6 +4,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { MockSilence } from "__fixtures__/Alerts";
import { MockThemeContext } from "__fixtures__/Theme";
import { inReactiveContext } from "__fixtures__/MobX";
import type { APISilenceT } from "Models/APITypes";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
@@ -88,18 +89,20 @@ describe("<ManagedSilence />", () => {
});
it("GetAlertmanager() returns alertmanager object from alertStore.data.upstreams.instances", () => {
const am = GetAlertmanager(alertStore, cluster);
expect(am).toEqual({
name: "am1",
cluster: "am",
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.24.0",
headers: {},
corsCredentials: "include",
inReactiveContext(() => {
const am = GetAlertmanager(alertStore, cluster);
expect(am).toEqual({
name: "am1",
cluster: "am",
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.24.0",
headers: {},
corsCredentials: "include",
});
});
});
@@ -135,18 +138,20 @@ describe("<ManagedSilence />", () => {
clusters: { am: ["am1", "am2"] },
});
const am = GetAlertmanager(alertStore, cluster);
expect(am).toEqual({
name: "am1",
cluster: "am",
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.24.0",
headers: {},
corsCredentials: "include",
inReactiveContext(() => {
const am = GetAlertmanager(alertStore, cluster);
expect(am).toEqual({
name: "am1",
cluster: "am",
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.24.0",
headers: {},
corsCredentials: "include",
});
});
});

View File

@@ -1,5 +1,6 @@
import { FC, useEffect, useState } from "react";
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import { parseISO } from "date-fns/parseISO";
@@ -55,14 +56,14 @@ const ManagedSilence: FC<{
const [showDetails, setShowDetails] = useState<boolean>(isOpen);
const onEditSilence = () => {
const onEditSilence = action(() => {
const alertmanager = GetAlertmanager(alertStore, cluster);
silenceFormStore.data.fillFormFromSilence(alertmanager, silence);
silenceFormStore.data.resetProgress();
silenceFormStore.tab.setTab("editor");
silenceFormStore.toggle.show();
};
});
const [progress, setProgress] = useState<number>(() =>
calculatePercent(silence.startsAt, silence.endsAt),

View File

@@ -7,6 +7,8 @@ import React, {
useEffect,
} from "react";
import { observer } from "mobx-react-lite";
import { useFloating, shift, flip, offset, size } from "@floating-ui/react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -195,10 +197,12 @@ const SilenceDeleteModalContent: FC<{
export const MassDeleteProgress: FC<{
alertStore: AlertStore;
silences: ClusterSilenceT[];
}> = ({ alertStore, silences }) => {
}> = observer(({ alertStore, silences }) => {
const [done, setDone] = useState(0);
const [errors, setErrors] = useState<string[]>([]);
const readWriteAlertmanagers = alertStore.data.readWriteAlertmanagers;
useEffect(() => {
const deleteSilence = async (
cluster: string,
@@ -237,7 +241,7 @@ export const MassDeleteProgress: FC<{
const timers: ReturnType<typeof setTimeout>[] = [];
silences.forEach((silence, index) => {
const ams = alertStore.data.readWriteAlertmanagers.filter(
const ams = readWriteAlertmanagers.filter(
(u) => u.cluster === silence.cluster,
);
// eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- false positive: timers are collected and cleared in the useEffect cleanup below
@@ -296,4 +300,4 @@ export const MassDeleteProgress: FC<{
) : null}
</>
);
};
});

View File

@@ -1,5 +1,6 @@
import { FC, useEffect, useState, MouseEvent, SyntheticEvent } from "react";
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import copy from "copy-to-clipboard";
@@ -177,7 +178,7 @@ const SilenceForm: FC<{
silenceFormStore.data.setComment(comment);
};
const handleSubmit = (event: SyntheticEvent<HTMLFormElement>) => {
const handleSubmit = action((event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const rbc: { [label: string]: ClusterRequestT } = {};
@@ -192,7 +193,7 @@ const SilenceForm: FC<{
silenceFormStore.data.setStage("preview");
silenceFormStore.data.setWasValidated(true);
};
});
return (
<form onSubmit={handleSubmit} autoComplete="on">

View File

@@ -1,6 +1,7 @@
import fetchMock from "@fetch-mock/jest";
import { EmptyAPIResponse } from "__fixtures__/Fetch";
import { inReactiveContext } from "__fixtures__/MobX";
import { MockGroup } from "__fixtures__/Stories";
import {
AlertStore,
@@ -129,8 +130,10 @@ describe("AlertStore.data", () => {
},
],
});
expect(store.data.clustersWithoutReadOnly).toEqual({
default: ["am2", "am1"],
inReactiveContext(() => {
expect(store.data.clustersWithoutReadOnly).toEqual({
default: ["am2", "am1"],
});
});
});
@@ -178,8 +181,10 @@ describe("AlertStore.data", () => {
},
],
});
expect(store.data.clustersWithoutReadOnly).toEqual({
default: ["am1", "am2", "am3"],
inReactiveContext(() => {
expect(store.data.clustersWithoutReadOnly).toEqual({
default: ["am1", "am2", "am3"],
});
});
});
});

View File

@@ -10,6 +10,7 @@ import {
MockSilence,
MockAlertmanager,
} from "__fixtures__/Alerts";
import { inReactiveContext } from "__fixtures__/MobX";
import { StringToOption, OptionT, MultiValueOptionT } from "Common/Select";
import {
SilenceFormStore,
@@ -826,14 +827,18 @@ describe("SilenceFormStore.data", () => {
it("toAlertmanagerPayload contains id when store.data.silenceID is set", () => {
store.data.setSilenceID("12345");
expect(store.data.toAlertmanagerPayload).toMatchObject({
id: "12345",
inReactiveContext(() => {
expect(store.data.toAlertmanagerPayload).toMatchObject({
id: "12345",
});
});
});
it("toAlertmanagerPayload doesn't contain id when store.data.silenceID is null", () => {
store.data.setSilenceID(null);
expect(store.data.toAlertmanagerPayload.id).toBeUndefined();
inReactiveContext(() => {
expect(store.data.toAlertmanagerPayload.id).toBeUndefined();
});
});
it("toAlertmanagerPayload creates payload that matches snapshot", () => {
@@ -851,7 +856,9 @@ describe("SilenceFormStore.data", () => {
store.data.setEnd(new Date(Date.UTC(2000, 1, 1, 1, 0, 0)));
store.data.setAuthor("me@example.com");
store.data.setComment("toAlertmanagerPayload test");
expect(store.data.toAlertmanagerPayload).toMatchSnapshot();
inReactiveContext(() => {
expect(store.data.toAlertmanagerPayload).toMatchSnapshot();
});
});
it("toAlertmanagerPayload creates payload that matches snapshot with regex values", () => {
@@ -957,7 +964,9 @@ describe("SilenceFormStore.data", () => {
store.data.setEnd(new Date(Date.UTC(2000, 1, 1, 1, 0, 0)));
store.data.setAuthor("me@example.com");
store.data.setComment("toAlertmanagerPayload test");
expect(store.data.toAlertmanagerPayload).toMatchSnapshot();
inReactiveContext(() => {
expect(store.data.toAlertmanagerPayload).toMatchSnapshot();
});
});
it("dumps to base64 and back", () => {
@@ -970,7 +979,7 @@ describe("SilenceFormStore.data", () => {
store.data.setStart(new Date());
store.data.setEnd(addMinutes(addHours(store.data.startsAt, 7), 45));
store.data.setComment("base64");
const b64 = store.data.toBase64;
const b64 = inReactiveContext(() => store.data.toBase64);
store.data.setMatchers([]);
store.data.setComment("");
@@ -1007,7 +1016,7 @@ describe("SilenceFormStore.data", () => {
it("base64 restore ignores empty matchers", () => {
store.data.setMatchers([]);
store.data.setComment("base64");
const b64 = store.data.toBase64;
const b64 = inReactiveContext(() => store.data.toBase64);
store.data.setMatchers([]);
store.data.setComment("foo");
@@ -1048,7 +1057,9 @@ describe("SilenceFormStore.data.isValid", () => {
]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if matchers list is empty", () => {
@@ -1056,7 +1067,9 @@ describe("SilenceFormStore.data.isValid", () => {
store.data.setMatchers([]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if matchers list is populated when a matcher without any name", () => {
@@ -1066,7 +1079,9 @@ describe("SilenceFormStore.data.isValid", () => {
]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if matchers list is populated when a matcher without any value ([])", () => {
@@ -1074,7 +1089,9 @@ describe("SilenceFormStore.data.isValid", () => {
store.data.setMatchers([MockMatcher("foo", [])]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if matchers list is populated when a matcher with empty value ([''])", () => {
@@ -1082,7 +1099,9 @@ describe("SilenceFormStore.data.isValid", () => {
store.data.setMatchers([MockMatcher("foo", [])]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if author is empty", () => {
@@ -1092,7 +1111,9 @@ describe("SilenceFormStore.data.isValid", () => {
]);
store.data.setAuthor("");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'false' if comment is empty", () => {
@@ -1102,7 +1123,9 @@ describe("SilenceFormStore.data.isValid", () => {
]);
store.data.setAuthor("me@example.com");
store.data.setComment("");
expect(store.data.isValid).toBe(false);
inReactiveContext(() => {
expect(store.data.isValid).toBe(false);
});
});
it("isValid returns 'true' if all fields are set", () => {
@@ -1112,7 +1135,9 @@ describe("SilenceFormStore.data.isValid", () => {
]);
store.data.setAuthor("me@example.com");
store.data.setComment("fake silence");
expect(store.data.isValid).toBe(true);
inReactiveContext(() => {
expect(store.data.isValid).toBe(true);
});
});
});
@@ -1120,30 +1145,36 @@ describe("SilenceFormStore.data startsAt & endsAt validation", () => {
it("toDuration returns correct duration for 5d 0h 1m", () => {
store.data.setStart(new Date(2000, 1, 1, 0, 0, 0));
store.data.setEnd(new Date(2000, 1, 6, 0, 1, 15));
expect(store.data.toDuration).toMatchObject({
days: 5,
hours: 0,
minutes: 1,
inReactiveContext(() => {
expect(store.data.toDuration).toMatchObject({
days: 5,
hours: 0,
minutes: 1,
});
});
});
it("toDuration returns correct duration for 2h 15m", () => {
store.data.setStart(new Date(2000, 1, 1, 0, 0, 0));
store.data.setEnd(new Date(2000, 1, 1, 2, 15, 0));
expect(store.data.toDuration).toMatchObject({
days: 0,
hours: 2,
minutes: 15,
inReactiveContext(() => {
expect(store.data.toDuration).toMatchObject({
days: 0,
hours: 2,
minutes: 15,
});
});
});
it("toDuration returns correct duration for 59m", () => {
store.data.setStart(new Date(2000, 1, 1, 0, 10, 0));
store.data.setEnd(new Date(2000, 1, 1, 1, 9, 0));
expect(store.data.toDuration).toMatchObject({
days: 0,
hours: 0,
minutes: 59,
inReactiveContext(() => {
expect(store.data.toDuration).toMatchObject({
days: 0,
hours: 0,
minutes: 59,
});
});
});

View File

@@ -0,0 +1,11 @@
import { autorun } from "mobx";
function inReactiveContext<T>(fn: () => T): T {
let result: T;
autorun(() => {
result = fn();
})();
return result!;
}
export { inReactiveContext };

View File

@@ -23,7 +23,7 @@ fetchMock.mockGlobal();
configure({
enforceActions: "observed",
// computedRequiresReaction: true,
computedRequiresReaction: true,
// reactionRequiresObservable: true,
// observableRequiresReaction: true,
});