diff --git a/src/behaviours/update-app/installing-update-using-tray.test.ts b/src/behaviours/update-app/installing-update-using-tray.test.ts index d4ad3931a0..33ec5a6e14 100644 --- a/src/behaviours/update-app/installing-update-using-tray.test.ts +++ b/src/behaviours/update-app/installing-update-using-tray.test.ts @@ -300,7 +300,12 @@ describe("installing update using tray", () => { }); it("asks user to install update immediately", () => { - expect(askBooleanMock).toHaveBeenCalledWith("Do you want to install update some-version?"); + expect(askBooleanMock).toHaveBeenCalledWith({ + id: "install-update", + title: "Update Available", + question: + "Version some-version of Lens IDE is available and ready to be installed. Would you like to update now?", + }); }); describe("when user answers to install the update", () => { diff --git a/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts new file mode 100644 index 0000000000..abdaa31a53 --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Channel } from "../channel/channel-injection-token"; +import { channelInjectionToken } from "../channel/channel-injection-token"; + +type AskBooleanAnswerChannel = Channel<{ id: string; value: boolean }>; + +const askBooleanAnswerChannelInjectable = getInjectable({ + id: "ask-boolean-answer-channel", + + instantiate: (): AskBooleanAnswerChannel => ({ + id: "ask-boolean-answer", + }), + + injectionToken: channelInjectionToken, +}); + +export default askBooleanAnswerChannelInjectable; diff --git a/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts new file mode 100644 index 0000000000..10cdf4511e --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Channel } from "../channel/channel-injection-token"; +import { channelInjectionToken } from "../channel/channel-injection-token"; + +export interface AskBooleanQuestionParameters { id: string; title: string; question: string } +export type AskBooleanQuestionChannel = Channel; + +const askBooleanQuestionChannelInjectable = getInjectable({ + id: "ask-boolean-question-channel", + + instantiate: (): AskBooleanQuestionChannel => ({ + id: "ask-boolean-question", + }), + + injectionToken: channelInjectionToken, +}); + +export default askBooleanQuestionChannelInjectable; diff --git a/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap new file mode 100644 index 0000000000..bdb79f3832 --- /dev/null +++ b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap @@ -0,0 +1,336 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ask-boolean given started when asking multiple questions renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking multiple questions when answering to first question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "no" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "yes" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user closes notification without answering the question renders 1`] = ` + +
+
+
+ +`; diff --git a/src/main/ask-boolean/ask-boolean-promise.injectable.ts b/src/main/ask-boolean/ask-boolean-promise.injectable.ts new file mode 100644 index 0000000000..c348d74169 --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-promise.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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"; + +const askBooleanPromiseInjectable = getInjectable({ + id: "ask-boolean-promise", + + instantiate: (di, questionId: string) => { + void questionId; + + let resolve: (value: boolean) => void; + let _promise: Promise; + + return ({ + get promise() { + return _promise; + }, + + clear: () => { + _promise = new Promise(_resolve => { + resolve = _resolve; + }); + }, + + resolve: (value: boolean) => { + resolve(value); }, + }); + }, + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, questionId: string) => questionId, + }), +}); + +export default askBooleanPromiseInjectable; diff --git a/src/main/ask-boolean/ask-boolean-return-value-listener.injectable.ts b/src/main/ask-boolean/ask-boolean-return-value-listener.injectable.ts new file mode 100644 index 0000000000..a5b271855f --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-return-value-listener.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { channelListenerInjectionToken } from "../../common/channel/channel-listener-injection-token"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; + +const askBooleanReturnValueListenerInjectable = getInjectable({ + id: "ask-boolean-return-value-listener", + + instantiate: (di) => ({ + channel: di.inject(askBooleanAnswerChannelInjectable), + + handler: ({ id, value }) => { + const returnValuePromise = di.inject(askBooleanPromiseInjectable, id); + + returnValuePromise.resolve(value); + }, + }), + + injectionToken: channelListenerInjectionToken, +}); + +export default askBooleanReturnValueListenerInjectable; diff --git a/src/main/ask-boolean/ask-boolean.injectable.ts b/src/main/ask-boolean/ask-boolean.injectable.ts index 1981d84ded..4f1f6b07fd 100644 --- a/src/main/ask-boolean/ask-boolean.injectable.ts +++ b/src/main/ask-boolean/ask-boolean.injectable.ts @@ -3,12 +3,37 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import { sendToAgnosticChannelInjectionToken } from "../../common/channel/send-to-agnostic-channel-injection-token"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; -export type AskBoolean = (question: string) => Promise; +export type AskBoolean = ({ + id, + title, + question, +}: { + id: string; + title: string; + question: string; +}) => Promise; const askBooleanInjectable = getInjectable({ id: "ask-boolean", - instantiate: (di): AskBoolean => async (question: string) => false, + + instantiate: (di): AskBoolean => { + const sendToAgnosticChannel = di.inject(sendToAgnosticChannelInjectionToken); + const askBooleanChannel = di.inject(askBooleanQuestionChannelInjectable); + + return async ({ id, title, question }) => { + const returnValuePromise = di.inject(askBooleanPromiseInjectable, id); + + returnValuePromise.clear(); + + await sendToAgnosticChannel(askBooleanChannel, { id, title, question }); + + return await returnValuePromise.promise; + }; + }, }); export default askBooleanInjectable; diff --git a/src/main/ask-boolean/ask-boolean.test.ts b/src/main/ask-boolean/ask-boolean.test.ts new file mode 100644 index 0000000000..db646f687b --- /dev/null +++ b/src/main/ask-boolean/ask-boolean.test.ts @@ -0,0 +1,201 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { AskBoolean } from "./ask-boolean.injectable"; +import askBooleanInjectable from "./ask-boolean.injectable"; +import { getPromiseStatus } from "../../common/test-utils/get-promise-status"; +import type { RenderResult } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; + +describe("ask-boolean", () => { + let applicationBuilder: ApplicationBuilder; + let askBoolean: AskBoolean; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + askBoolean = applicationBuilder.dis.mainDi.inject(askBooleanInjectable); + }); + + describe("given started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + describe("when asking question", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = askBoolean({ + id: "some-question-id", + title: "some-title", + question: "Some question", + }); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification", () => { + const notification = rendered.getByTestId("ask-boolean-some-question-id"); + + expect(notification).not.toBeUndefined(); + }); + + describe('when user answers "yes"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-question-id-button-yes"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-question-id"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(true); + }); + }); + + describe('when user answers "no"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-question-id-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-question-id"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + + describe("when user closes notification without answering the question", () => { + beforeEach(() => { + const button = rendered.getByTestId("close-notification-for-ask-boolean-for-some-question-id"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-question-id"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + }); + + describe("when asking multiple questions", () => { + let firstQuestionPromise: Promise; + let secondQuestionPromise: Promise; + + beforeEach(() => { + firstQuestionPromise = askBoolean({ + id: "some-question-id", + title: "some-title", + question: "Some question", + }); + + secondQuestionPromise = askBoolean({ + id: "some-other-question-id", + title: "some-other-title", + question: "Some other question", + }); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification for first question", () => { + const notification = rendered.getByTestId("ask-boolean-some-question-id"); + + expect(notification).not.toBeUndefined(); + }); + + it("shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-other-question-id"); + + expect(notification).not.toBeUndefined(); + }); + + describe("when answering to first question", () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-question-id-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification for first question anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-question-id"); + + expect(notification).toBeNull(); + }); + + it("still shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-other-question-id"); + + expect(notification).not.toBeUndefined(); + }); + + it("resolves first question", async () => { + const actual = await firstQuestionPromise; + + expect(actual).toBe(false); + }); + + it("does not resolve second question yet", async () => { + const promiseStatus = await getPromiseStatus(secondQuestionPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + }); + }); + }); +}); diff --git a/src/main/update-app/check-for-updates-tray-item.injectable.ts b/src/main/update-app/check-for-updates-tray-item.injectable.ts index 41f6391dc8..8a0c7816ca 100644 --- a/src/main/update-app/check-for-updates-tray-item.injectable.ts +++ b/src/main/update-app/check-for-updates-tray-item.injectable.ts @@ -76,7 +76,11 @@ const checkForUpdatesTrayItemInjectable = getInjectable({ return; } - const userWantsToInstallUpdate = await askBoolean(`Do you want to install update ${version}?`); + const userWantsToInstallUpdate = await askBoolean({ + id: "install-update", + title: "Update available", + question: `Version ${version} of Lens IDE is available and ready to be installed. Would you like to update now?`, + }); if (userWantsToInstallUpdate) { quitAndInstallUpdate(); diff --git a/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx new file mode 100644 index 0000000000..a99e6398d9 --- /dev/null +++ b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { channelListenerInjectionToken } from "../../common/channel/channel-listener-injection-token"; +import type { AskBooleanQuestionParameters } from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import showInfoNotificationInjectable from "../components/notifications/show-info-notification.injectable"; +import { Button } from "../components/button"; +import React from "react"; +import { sendToAgnosticChannelInjectionToken } from "../../common/channel/send-to-agnostic-channel-injection-token"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; + +const askBooleanQuestionChannelListenerInjectable = getInjectable({ + id: "ask-boolean-question-channel-listener", + + instantiate: (di) => { + const questionChannel = di.inject(askBooleanQuestionChannelInjectable); + const showInfoNotification = di.inject(showInfoNotificationInjectable); + const sendToAgnosticChannel = di.inject(sendToAgnosticChannelInjectionToken); + const answerChannel = di.inject(askBooleanAnswerChannelInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); + + const sendAnswerFor = (id: string) => (value: boolean) => { + sendToAgnosticChannel(answerChannel, { id, value }); + }; + + const closeNotification = (notificationId: string) => { + notificationsStore.remove(notificationId); + }; + + const sendAnswerAndCloseNotificationFor = (sendAnswer: (value: boolean) => void, notificationId: string) => (value: boolean) => () => { + sendAnswer(value); + closeNotification(notificationId); + }; + + return { + channel: questionChannel, + + handler: ({ id: questionId, title, question }: AskBooleanQuestionParameters) => { + const notificationId = `ask-boolean-for-${questionId}`; + + const sendAnswer = sendAnswerFor(questionId); + const sendAnswerAndCloseNotification = sendAnswerAndCloseNotificationFor(sendAnswer, notificationId); + + showInfoNotification( + , + + { + id: notificationId, + timeout: 0, + onClose: () => sendAnswer(false), + }, + ); + }, + }; + }, + + injectionToken: channelListenerInjectionToken, +}); + +export default askBooleanQuestionChannelListenerInjectable; + +const AskBoolean = ({ + id, + title, + message, + onNo, + onYes, +}: { + id: string; + title: string; + message: string; + onNo: () => void; + onYes: () => void; +}) => ( +
+ {title} +

{message}

+ +
+
+
+); diff --git a/src/renderer/components/notifications/show-info-notification.injectable.ts b/src/renderer/components/notifications/show-info-notification.injectable.ts index 47c91715cc..9a74f5efc6 100644 --- a/src/renderer/components/notifications/show-info-notification.injectable.ts +++ b/src/renderer/components/notifications/show-info-notification.injectable.ts @@ -3,19 +3,24 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { notificationsStore, NotificationStatus } from "./notifications.store"; +import type { NotificationMessage, Notification } from "./notifications.store"; +import { NotificationStatus } from "./notifications.store"; +import notificationsStoreInjectable from "./notifications-store.injectable"; const showInfoNotificationInjectable = getInjectable({ id: "show-info-notification", - instantiate: () => (message: string) => - notificationsStore.add({ - status: NotificationStatus.INFO, - timeout: 5000, - message, - }), + instantiate: (di) => { + const notificationsStore = di.inject(notificationsStoreInjectable); - causesSideEffects: true, + return (message: NotificationMessage, customOpts: Partial> = {}) => + notificationsStore.add({ + status: NotificationStatus.INFO, + timeout: 5000, + message, + ...customOpts, + }); + }, }); export default showInfoNotificationInjectable;