diff --git a/src/renderer/components/countdown/countdown-state.injectable.ts b/src/renderer/components/countdown/countdown-state.injectable.ts new file mode 100644 index 0000000000..6cd290b4cf --- /dev/null +++ b/src/renderer/components/countdown/countdown-state.injectable.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { + computed, + observable, + onBecomeObserved, + onBecomeUnobserved, + runInAction, +} from "mobx"; + +const countdownStateInjectable = getInjectable({ + id: "countdown-state", + + instantiate: ( + di, + { startFrom, onZero }: { startFrom: number; onZero: () => void }, + ) => { + const state = observable.box(startFrom); + + let intervalId: NodeJS.Timer | undefined; + + const stop = () => { + clearInterval(intervalId); + }; + + const start = () => { + intervalId = setInterval(() => { + const secondsLeft = state.get() - 1; + + runInAction(() => { + state.set(secondsLeft); + }); + + if (secondsLeft === 0) { + stop(); + onZero(); + } + }, 1000); + }; + + onBecomeObserved(state, start); + onBecomeUnobserved(state, stop); + + return computed(() => state.get()); + }, + + lifecycle: lifecycleEnum.transient, +}); + +export default countdownStateInjectable; diff --git a/src/renderer/components/countdown/countdown.test.tsx b/src/renderer/components/countdown/countdown.test.tsx new file mode 100644 index 0000000000..9bb08f486b --- /dev/null +++ b/src/renderer/components/countdown/countdown.test.tsx @@ -0,0 +1,142 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { createContainer } from "@ogre-tools/injectable"; +import countdownStateInjectable from "./countdown-state.injectable"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import { Countdown } from "./countdown"; +import React from "react"; +import type { RenderResult } from "@testing-library/react"; +import { advanceFakeTime, useFakeTime } from "../../../common/test-utils/use-fake-time"; +import type { IComputedValue } from "mobx"; +import { observe } from "mobx"; +import { noop } from "../../../common/utils"; + +describe("countdown", () => { + let di: DiContainer; + let render: DiRender; + + beforeEach(() => { + useFakeTime("2015-10-21T07:28:00Z"); + + di = createContainer("irrelevant"); + + render = renderFor(di); + + di.register(countdownStateInjectable); + }); + + describe("when rendering countdown", () => { + let rendered: RenderResult; + let onZeroMock: jest.Mock; + + beforeEach(() => { + onZeroMock = jest.fn(); + + const secondsTill = di.inject(countdownStateInjectable, { + startFrom: 42, + onZero: onZeroMock, + }); + + rendered = render( + , + ); + }); + + it("renders with initial seconds", () => { + expect(rendered.container).toHaveTextContent("42"); + }); + + describe("when time passes", () => { + beforeEach(() => { + advanceFakeTime(1000); + }); + + it("updates the seconds", () => { + expect(rendered.container).toHaveTextContent("41"); + }); + + it("does not call callback yet", () => { + expect(onZeroMock).not.toHaveBeenCalled(); + }); + }); + + it("when just not enough time passes to fulfill the countdown, does not call the callback yet", () => { + advanceFakeTime(41 * 1000); + + expect(onZeroMock).not.toHaveBeenCalled(); + }); + + describe("when time passes enough to fulfill the countdown", () => { + beforeEach(() => { + advanceFakeTime(42 * 1000); + }); + + it("shows zero as seconds", () => { + expect(rendered.container).toHaveTextContent("0"); + }); + + it("calls the callback", () => { + expect(onZeroMock).toHaveBeenCalled(); + }); + + describe("when time passes even more", () => { + beforeEach(() => { + onZeroMock.mockClear(); + + advanceFakeTime(1000); + }); + + it("does not update the countdown anymore", () => { + expect(rendered.container).toHaveTextContent("0"); + }); + + it("does not call the callback", () => { + expect(onZeroMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe("given observed", () => { + let onZeroMock: jest.Mock; + let unobserve: () => void; + let secondsTill: IComputedValue; + + beforeEach(() => { + onZeroMock = jest.fn(); + + secondsTill = di.inject(countdownStateInjectable, { + startFrom: 1, + onZero: onZeroMock, + }); + + unobserve = observe(secondsTill, noop); + }); + + describe("given unobserved, when enough time passes so that it would fulfill the countdown", () => { + beforeEach(() => { + onZeroMock.mockClear(); + + unobserve(); + + advanceFakeTime(1000); + }); + + it("does not call callback yet", () => { + expect(onZeroMock).not.toHaveBeenCalled(); + }); + + it("given observed again, when time passes to fulfill the countdown, calls the callback", () => { + observe(secondsTill, noop); + + advanceFakeTime(1000); + + expect(onZeroMock).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/renderer/components/countdown/countdown.tsx b/src/renderer/components/countdown/countdown.tsx new file mode 100644 index 0000000000..82b0d60e92 --- /dev/null +++ b/src/renderer/components/countdown/countdown.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { IComputedValue } from "mobx"; +import { observer } from "mobx-react"; +import type { HTMLAttributes } from "react"; +import React from "react"; + +interface CountdownProps extends HTMLAttributes { + secondsTill: IComputedValue; +} + +export const Countdown = observer(({ secondsTill, ...props }: CountdownProps) => ( + {secondsTill.get()} +));