diff --git a/web/src/client/index.js b/web/src/client/index.js index 95185d56aa..d3770af9cb 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -24,7 +24,6 @@ import { L10nClient } from "./l10n"; import { ManagerClient } from "./manager"; import { StorageClient } from "./storage"; -import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; import { HTTPClient, WSClient } from "./http"; @@ -34,7 +33,6 @@ import { HTTPClient, WSClient } from "./http"; * @property {ManagerClient} manager - manager client. * @property {NetworkClient} network - network client. * @property {StorageClient} storage - storage client. - * @property {QuestionsClient} questions - questions client. * @property {() => WSClient} ws - Agama WebSocket client. * @property {() => boolean} isConnected - determines whether the client is connected * @property {() => boolean} isRecoverable - determines whether the client is recoverable after disconnected @@ -57,7 +55,6 @@ const createClient = (url) => { const manager = new ManagerClient(client); const network = new NetworkClient(client); const storage = new StorageClient(client); - const questions = new QuestionsClient(client); const isConnected = () => client.ws().isConnected() || false; const isRecoverable = () => !!client.ws().isRecoverable(); @@ -67,7 +64,6 @@ const createClient = (url) => { manager, network, storage, - questions, isConnected, isRecoverable, onConnect: (handler) => client.ws().onOpen(handler), diff --git a/web/src/client/questions.js b/web/src/client/questions.js deleted file mode 100644 index d2e822c238..0000000000 --- a/web/src/client/questions.js +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -const QUESTION_TYPES = { - generic: "generic", - withPassword: "withPassword", -}; - -/** - * @param {Object} httpQuestion - * @return {Object} - */ -function buildQuestion(httpQuestion) { - let question = {}; - if (httpQuestion.generic) { - question.type = QUESTION_TYPES.generic; - question = { ...httpQuestion.generic, type: QUESTION_TYPES.generic }; - question.answer = httpQuestion.generic.answer; - } - - if (httpQuestion.withPassword) { - question.type = QUESTION_TYPES.withPassword; - question.password = httpQuestion.withPassword.password; - } - - return question; -} - -/** - * Questions client - */ -class QuestionsClient { - /** - * @param {import("./http").HTTPClient} client - HTTP client. - */ - constructor(client) { - this.client = client; - this.listening = false; - this.questionIds = []; - this.handlers = { - added: [], - removed: [], - }; - } - - /** - * Return all the questions - * - * @return {Promise>} - */ - async getQuestions() { - const response = await this.client.get("/questions"); - if (!response.ok) { - console.warn("Failed to get questions: ", response); - return []; - } - const questions = await response.json(); - return questions.map(buildQuestion); - } - - /** - * Answer with the information in the given question - * - * @param {Object} question - */ - answer(question) { - const answer = { generic: { answer: question.answer } }; - if (question.type === QUESTION_TYPES.withPassword) { - answer.withPassword = { password: question.password }; - } - - const path = `/questions/${question.id}/answer`; - return this.client.put(path, answer); - } - - /** - * Register a callback to run when a questions is added - * - * @param {function} handler - callback function - * @return {function} function to unsubscribe - */ - onQuestionAdded(handler) { - this.handlers.added.push(handler); - - return () => { - const position = this.handlers.added.indexOf(handler); - if (position > -1) this.handlers.added.splice(position, 1); - }; - } - - /** - * Register a callback to run when a questions is removed - * - * @param {function} handler - callback function - * @return {function} function to unsubscribe - */ - onQuestionRemoved(handler) { - this.handlers.removed.push(handler); - - return () => { - const position = this.handlers.removed.indexOf(handler); - if (position > -1) this.handlers.removed.splice(position, 1); - }; - } - - async listenQuestions() { - if (this.listening) return; - - this.listening = true; - this.getQuestions().then((qs) => { - this.questionIds = qs.map((q) => q.id); - }); - return this.client.onEvent("QuestionsChanged", () => { - this.getQuestions().then((qs) => { - const updatedIds = qs.map((q) => q.id); - - const newQuestions = qs.filter((q) => !this.questionIds.includes(q.id)); - newQuestions.forEach((q) => { - this.handlers.added.forEach((f) => f(q)); - }); - - const removed = this.questionIds.filter((id) => !updatedIds.includes(id)); - removed.forEach((id) => { - this.handlers.removed.forEach((f) => f(id)); - }); - - this.questionIds = updatedIds; - }); - }); - } -} - -export { QUESTION_TYPES, QuestionsClient }; diff --git a/web/src/client/questions.test.js b/web/src/client/questions.test.js deleted file mode 100644 index 21835aced5..0000000000 --- a/web/src/client/questions.test.js +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { HTTPClient } from "./http"; -import { QuestionsClient } from "./questions"; - -const mockJsonFn = jest.fn(); -const mockGetFn = jest.fn().mockImplementation(() => { - return { ok: true, json: mockJsonFn }; -}); -const mockPutFn = jest.fn().mockImplementation(() => { - return { ok: true }; -}); - -jest.mock("./http", () => { - return { - HTTPClient: jest.fn().mockImplementation(() => { - return { - get: mockGetFn, - put: mockPutFn, - onEvent: jest.fn(), - }; - }), - }; -}); - -let client; - -const expectedQuestions = [ - { - id: 432, - class: "storage.luks_activation", - type: "withPassword", - text: "The device /dev/vdb1 (2.00 GiB) is encrypted. Do you want to decrypt it?", - options: ["skip", "decrypt"], - defaultOption: "decrypt", - answer: "", - data: { Attempt: "1" }, - password: "", - }, -]; - -const luksQuestion = { - generic: { - id: 432, - class: "storage.luks_activation", - text: "The device /dev/vdb1 (2.00 GiB) is encrypted. Do you want to decrypt it?", - options: ["skip", "decrypt"], - defaultOption: "decrypt", - data: { Attempt: "1" }, - answer: "", - }, - withPassword: { password: "" }, -}; - -describe("#getQuestions", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue([luksQuestion]); - client = new QuestionsClient(new HTTPClient(new URL("http://localhost"))); - }); - - it("returns pending questions", async () => { - const questions = await client.getQuestions(); - expect(mockGetFn).toHaveBeenCalledWith("/questions"); - expect(questions).toEqual(expectedQuestions); - }); -}); - -describe("#answer", () => { - let question; - - beforeEach(() => { - question = { id: 321, type: "whatever", answer: "the-answer" }; - }); - - it("sets given answer", async () => { - client = new QuestionsClient(new HTTPClient(new URL("http://localhost"))); - await client.answer(question); - - expect(mockPutFn).toHaveBeenCalledWith("/questions/321/answer", { - generic: { answer: "the-answer" }, - }); - }); - - describe("when answering a question implementing the LUKS activation interface", () => { - beforeEach(() => { - question = { - id: 432, - type: "withPassword", - class: "storage.luks_activation", - answer: "decrypt", - password: "notSecret", - }; - }); - - it("sets given password", async () => { - client = new QuestionsClient(new HTTPClient(new URL("http://localhost"))); - await client.answer(question); - - expect(mockPutFn).toHaveBeenCalledWith("/questions/432/answer", { - generic: { answer: "decrypt" }, - withPassword: { password: "notSecret" }, - }); - }); - }); -}); diff --git a/web/src/components/questions/GenericQuestion.test.jsx b/web/src/components/questions/GenericQuestion.test.tsx similarity index 86% rename from web/src/components/questions/GenericQuestion.test.jsx rename to web/src/components/questions/GenericQuestion.test.tsx index ef3f3f176d..a45855a49b 100644 --- a/web/src/components/questions/GenericQuestion.test.jsx +++ b/web/src/components/questions/GenericQuestion.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -21,10 +21,11 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { GenericQuestion } from "~/components/questions"; +import { plainRender } from "~/test-utils"; +import { Question } from "~/types/questions"; +import GenericQuestion from "~/components/questions/GenericQuestion"; -const question = { +const question: Question = { id: 1, text: "Do you write unit tests?", options: ["always", "sometimes", "never"], @@ -34,7 +35,7 @@ const question = { const answerFn = jest.fn(); const renderQuestion = () => - installerRender(); + plainRender(); describe("GenericQuestion", () => { it("renders the question text", async () => { diff --git a/web/src/components/questions/GenericQuestion.jsx b/web/src/components/questions/GenericQuestion.tsx similarity index 70% rename from web/src/components/questions/GenericQuestion.jsx rename to web/src/components/questions/GenericQuestion.tsx index 8028b4be97..d1f2f34de9 100644 --- a/web/src/components/questions/GenericQuestion.jsx +++ b/web/src/components/questions/GenericQuestion.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -22,11 +22,24 @@ import React from "react"; import { Text } from "@patternfly/react-core"; import { Popup } from "~/components/core"; -import { QuestionActions } from "~/components/questions"; +import { AnswerCallback, Question } from "~/types/questions"; +import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; -export default function GenericQuestion({ question, answerCallback }) { - const actionCallback = (option) => { +/** + * Component for rendering generic questions + * + * @param question - the question to be answered + * @param answerCallback - the callback to be triggered on answer + */ +export default function GenericQuestion({ + question, + answerCallback, +}: { + question: Question; + answerCallback: AnswerCallback; +}): React.ReactNode { + const actionCallback = (option: string) => { question.answer = option; answerCallback(question); }; diff --git a/web/src/components/questions/LuksActivationQuestion.test.jsx b/web/src/components/questions/LuksActivationQuestion.test.tsx similarity index 70% rename from web/src/components/questions/LuksActivationQuestion.test.jsx rename to web/src/components/questions/LuksActivationQuestion.test.tsx index 6bee9e3754..3af25fc170 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.jsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -21,25 +21,27 @@ import React from "react"; import { screen } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { LuksActivationQuestion } from "~/components/questions"; - -let question; -const answerFn = jest.fn(); +import { plainRender } from "~/test-utils"; +import { AnswerCallback, Question } from "~/types/questions"; +import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; + +let question: Question; +const questionMock: Question = { + id: 1, + class: "storage.luks_activation", + text: "A Luks device found. Do you want to open it?", + options: ["decrypt", "skip"], + defaultOption: "decrypt", + data: { attempt: "1" }, +}; +const answerFn: AnswerCallback = jest.fn(); const renderQuestion = () => - installerRender(); + plainRender(); describe("LuksActivationQuestion", () => { beforeEach(() => { - question = { - id: 1, - class: "storage.luks_activation", - text: "A Luks device found. Do you want to open it?", - options: ["decrypt", "skip"], - defaultOption: "decrypt", - data: { attempt: "1" }, - }; + question = { ...questionMock }; }); it("renders the question text", async () => { @@ -48,53 +50,28 @@ describe("LuksActivationQuestion", () => { await screen.findByText(question.text); }); - it("contains a textinput for entering the password", async () => { - renderQuestion(); - - const passwordInput = await screen.findByLabelText("Encryption Password"); - expect(passwordInput).not.toBeNull(); - }); - describe("when it is the first attempt", () => { it("does not contain a warning", async () => { renderQuestion(); - const warning = screen.queryByText(/Given encryption password/); + const warning = screen.queryByText("The encryption password did not work"); expect(warning).toBeNull(); }); }); describe("when it is not the first attempt", () => { beforeEach(() => { - question = { - id: 1, - class: "storage.luks_activation", - text: "A Luks device found. Do you want to open it?", - options: ["decrypt", "skip"], - defaultOption: "decrypt", - data: { attempt: "3" }, - }; + question = { ...questionMock, data: { attempt: "2" } }; }); it("contains a warning", async () => { renderQuestion(); - await screen.findByText(/Given encryption password/); + await screen.findByText("The encryption password did not work"); }); }); describe("when the user selects one of the options", () => { - beforeEach(() => { - question = { - id: 1, - class: "storage.luks_activation", - text: "A Luks device found. Do you want to open it?", - options: ["decrypt", "skip"], - defaultOption: "decrypt", - data: { attempt: "1" }, - }; - }); - describe("by clicking on 'Skip'", () => { it("calls the callback after setting both, answer and password", async () => { const { user } = renderQuestion(); diff --git a/web/src/components/questions/LuksActivationQuestion.jsx b/web/src/components/questions/LuksActivationQuestion.tsx similarity index 60% rename from web/src/components/questions/LuksActivationQuestion.jsx rename to web/src/components/questions/LuksActivationQuestion.tsx index 8e31b915fc..9b666ddd18 100644 --- a/web/src/components/questions/LuksActivationQuestion.jsx +++ b/web/src/components/questions/LuksActivationQuestion.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -20,27 +20,36 @@ */ import React, { useState } from "react"; -import { Alert, Form, FormGroup, Text } from "@patternfly/react-core"; +import { Alert as PFAlert, Form, FormGroup, Text, Stack } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; import { PasswordInput, Popup } from "~/components/core"; -import { QuestionActions } from "~/components/questions"; +import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; -const renderAlert = (attempt) => { - if (!attempt || attempt === 1) return null; +/** + * Internal component for rendering an alert if given password failed + */ +const Alert = ({ attempt }: { attempt?: string }): React.ReactNode => { + if (!attempt || parseInt(attempt) === 1) return null; return ( // TRANSLATORS: error message, user entered a wrong password - + ); }; +/** + * Component for rendering questions related to LUKS activation + * + * @param question - the question to be answered + * @param answerCallback - the callback to be triggered on answer + */ export default function LuksActivationQuestion({ question, answerCallback }) { const [password, setPassword] = useState(question.password || ""); const conditions = { disable: { decrypt: password === "" } }; const defaultAction = "decrypt"; - const actionCallback = (option) => { + const actionCallback = (option: string) => { question.password = password; question.answer = option; answerCallback(question); @@ -60,19 +69,21 @@ export default function LuksActivationQuestion({ question, answerCallback }) { aria-label={_("Question")} titleIconVariant={() => } > - {renderAlert(parseInt(question.data.attempt))} - {question.text} -
- {/* TRANSLATORS: field label */} - - setPassword(value)} - /> - -
+ + + {question.text} +
+ {/* TRANSLATORS: field label */} + + setPassword(value)} + /> + +
+
`${text[0].toUpperCase()}${text.slice(1)}`; +const label = (text: string): string => `${text[0].toUpperCase()}${text.slice(1)}`; /** * A component for building a Question actions, using the defaultAction @@ -42,13 +39,23 @@ const label = (text) => `${text[0].toUpperCase()}${text.slice(1)}`; * React.Fragment (aka <>) here for wrapping the actions instead of directly using the Popup.Actions. * * @param {object} props - component props - * @param {Array.} props.actions - the actions to be shown - * @param {String} [props.defaultAction] - the action to be shown as primary - * @param {function} props.actionCallback - the function to be called when user clicks on action - * @param {Object} [props.conditions={}] - an object holding conditions, like when an action is disabled + * @param props.actions - the actions show + * @param props.defaultAction - the action to show as primary + * @param props.actionCallback - the function to call when the user clicks on the action + * @param props.conditions={} - an object holding conditions, like when an action is disabled */ -export default function QuestionActions({ actions, defaultAction, actionCallback, conditions }) { - let [[primaryAction], secondaryActions] = partition(actions, (a) => a === defaultAction); +export default function QuestionActions({ + actions, + defaultAction, + actionCallback, + conditions = {}, +}: { + actions: string[]; + defaultAction?: string; + actionCallback: (action: string) => void; + conditions?: { disable?: { [key: string]: boolean } }; +}): React.ReactNode { + let [[primaryAction], secondaryActions] = partition(actions, (a: string) => a === defaultAction); // Ensure there is always a primary action if (!primaryAction) [primaryAction, ...secondaryActions] = secondaryActions; diff --git a/web/src/components/questions/QuestionWithPassword.test.jsx b/web/src/components/questions/QuestionWithPassword.test.tsx similarity index 81% rename from web/src/components/questions/QuestionWithPassword.test.jsx rename to web/src/components/questions/QuestionWithPassword.test.tsx index c9831d72d0..371daaf20a 100644 --- a/web/src/components/questions/QuestionWithPassword.test.jsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -22,26 +22,22 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { QuestionWithPassword } from "~/components/questions"; +import { Question } from "~/types/questions"; +import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; -let question; const answerFn = jest.fn(); +const question: Question = { + id: 1, + class: "question.password", + text: "Random question. Will you provide random password?", + options: ["ok", "cancel"], + defaultOption: "cancel", +}; const renderQuestion = () => plainRender(); describe("QuestionWithPassword", () => { - beforeEach(() => { - question = { - id: 1, - class: "question.password", - text: "Random question. Will you provide random password?", - options: ["ok", "cancel"], - defaultOption: "cancel", - data: {}, - }; - }); - it("renders the question text", () => { renderQuestion(); @@ -49,12 +45,12 @@ describe("QuestionWithPassword", () => { }); describe("when the user enters the password", () => { - it("calls the callback", async () => { + it("calls the callback with given password", async () => { const { user } = renderQuestion(); const passwordInput = await screen.findByLabelText("Password"); await user.type(passwordInput, "notSecret"); - const skipButton = await screen.findByRole("button", { name: /Ok/ }); + const skipButton = await screen.findByRole("button", { name: "Ok" }); await user.click(skipButton); expect(question).toEqual(expect.objectContaining({ password: "notSecret", answer: "ok" })); diff --git a/web/src/components/questions/QuestionWithPassword.jsx b/web/src/components/questions/QuestionWithPassword.tsx similarity index 60% rename from web/src/components/questions/QuestionWithPassword.jsx rename to web/src/components/questions/QuestionWithPassword.tsx index 14e534ec44..e10947b488 100644 --- a/web/src/components/questions/QuestionWithPassword.jsx +++ b/web/src/components/questions/QuestionWithPassword.tsx @@ -20,17 +20,30 @@ */ import React, { useState } from "react"; -import { Alert, Form, FormGroup, Text } from "@patternfly/react-core"; +import { Form, FormGroup, Stack, Text } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; import { PasswordInput, Popup } from "~/components/core"; -import { QuestionActions } from "~/components/questions"; +import { AnswerCallback, Question } from "~/types/questions"; +import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; -export default function QuestionWithPassword({ question, answerCallback }) { +/** + * Component for rendering questions asking for password + * + * @param question - the question to be answered + * @param answerCallback - the callback to be triggered on answer + */ +export default function QuestionWithPassword({ + question, + answerCallback, +}: { + question: Question; + answerCallback: AnswerCallback; +}): React.ReactNode { const [password, setPassword] = useState(question.password || ""); const defaultAction = question.defaultOption; - const actionCallback = (option) => { + const actionCallback = (option: string) => { question.password = password; question.answer = option; answerCallback(question); @@ -42,18 +55,20 @@ export default function QuestionWithPassword({ question, answerCallback }) { title={_("Password Required")} titleIconVariant={() => } > - {question.text} -
- {/* TRANSLATORS: field label */} - - setPassword(value)} - /> - -
+ + {question.text} +
+ {/* TRANSLATORS: field label */} + + setPassword(value)} + /> + +
+
{ - setPendingQuestions((pending) => [...pending, question]); - }, []); - - const removeQuestion = useCallback( - (id) => setPendingQuestions((pending) => pending.filter((q) => q.id !== id)), - [], - ); - - const answerQuestion = useCallback( - (question) => { - client.questions.answer(question); - removeQuestion(question.id); - }, - [client.questions, removeQuestion], - ); - - useEffect(() => { - client.questions.listenQuestions(); - }, [client.questions, cancellablePromise]); - - useEffect(() => { - cancellablePromise(client.questions.getQuestions()) - .then(setPendingQuestions) - .catch((e) => console.error("Something went wrong retrieving pending questions", e)); - }, [client.questions, cancellablePromise]); - - useEffect(() => { - const unsubscribeCallbacks = []; - unsubscribeCallbacks.push(client.questions.onQuestionAdded(addQuestion)); - unsubscribeCallbacks.push(client.questions.onQuestionRemoved(removeQuestion)); - - return () => { - unsubscribeCallbacks.forEach((cb) => cb()); - }; - }, [client.questions, addQuestion, removeQuestion]); - - if (pendingQuestions.length === 0) return null; - - // Renders the first pending question - const [currentQuestion] = pendingQuestions; - let QuestionComponent = GenericQuestion; - // show specialized popup for question which need password - if (currentQuestion.type === QUESTION_TYPES.withPassword) { - QuestionComponent = QuestionWithPassword; - } - // show specialized popup for luks activation question - // more can follow as it will be needed - if (currentQuestion.class === "storage.luks_activation") { - QuestionComponent = LuksActivationQuestion; - } - return ; -} diff --git a/web/src/components/questions/Questions.test.jsx b/web/src/components/questions/Questions.test.jsx deleted file mode 100644 index e951a853f9..0000000000 --- a/web/src/components/questions/Questions.test.jsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; - -import { act, waitFor, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; - -import { Questions } from "~/components/questions"; - -jest.mock("~/client"); -jest.mock("~/components/questions/GenericQuestion", () => () =>
A Generic question mock
); -jest.mock("~/components/questions/LuksActivationQuestion", () => () => ( -
A LUKS activation question mock
-)); - -const handlers = {}; -const genericQuestion = { id: 1, type: "generic" }; -const luksActivationQuestion = { id: 1, class: "storage.luks_activation" }; -let pendingQuestions = []; - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - questions: { - getQuestions: () => Promise.resolve(pendingQuestions), - // Capture the handler for the onQuestionAdded signal for triggering it manually - onQuestionAdded: (onAddHandler) => { - handlers.onAdd = onAddHandler; - return jest.fn; - }, - // Capture the handler for the onQuestionREmoved signal for triggering it manually - onQuestionRemoved: (onRemoveHandler) => { - handlers.onRemove = onRemoveHandler; - return jest.fn; - }, - listenQuestions: jest.fn(), - }, - }; - }); -}); - -describe("Questions", () => { - describe("when there are no pending questions", () => { - beforeEach(() => { - pendingQuestions = []; - }); - - it("renders nothing", async () => { - const { container } = installerRender(); - await waitFor(() => expect(container).toBeEmptyDOMElement()); - }); - }); - - describe("when a new question is added", () => { - it("push it into the pending queue", async () => { - const { container } = installerRender(); - await waitFor(() => expect(container).toBeEmptyDOMElement()); - - // Manually triggers the handler given for the onQuestionAdded signal - act(() => handlers.onAdd(genericQuestion)); - - await within(container).findByText("A Generic question mock"); - }); - }); - - describe("when a question is removed", () => { - beforeEach(() => { - pendingQuestions = [genericQuestion]; - }); - - it("removes it from the queue", async () => { - const { container } = installerRender(); - await within(container).findByText("A Generic question mock"); - - // Manually triggers the handler given for the onQuestionRemoved signal - act(() => handlers.onRemove(genericQuestion.id)); - - const content = within(container).queryByText("A Generic question mock"); - expect(content).toBeNull(); - }); - }); - - describe("when there is a generic question pending", () => { - beforeEach(() => { - pendingQuestions = [genericQuestion]; - }); - - it("renders a GenericQuestion component", async () => { - const { container } = installerRender(); - - await within(container).findByText("A Generic question mock"); - }); - }); - - describe("when there is a LUKS activation question pending", () => { - beforeEach(() => { - pendingQuestions = [luksActivationQuestion]; - }); - - it("renders a LuksActivationQuestion component", async () => { - const { container } = installerRender(); - - await within(container).findByText("A LUKS activation question mock"); - }); - }); -}); diff --git a/web/src/components/questions/Questions.test.tsx b/web/src/components/questions/Questions.test.tsx new file mode 100644 index 0000000000..31ce42bb9b --- /dev/null +++ b/web/src/components/questions/Questions.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright (c) [2022-2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender, plainRender } from "~/test-utils"; +import { Question, QuestionType } from "~/types/questions"; +import Questions from "~/components/questions/Questions"; +import * as GenericQuestionComponent from "~/components/questions/GenericQuestion"; + +let mockQuestions: Question[]; +const mockMutation = jest.fn(); + +jest.mock("~/components/questions/LuksActivationQuestion", () => () => ( +
A LUKS activation question mock
+)); +jest.mock("~/components/questions/QuestionWithPassword", () => () => ( +
A question with password mock
+)); + +jest.mock("~/queries/questions", () => ({ + ...jest.requireActual("~/queries/software"), + useQuestions: () => mockQuestions, + useQuestionsChanges: () => jest.fn(), + useQuestionsConfig: () => ({ mutate: mockMutation }), +})); + +const genericQuestion: Question = { + id: 1, + type: QuestionType.generic, + text: "Do you write unit tests?", + options: ["always", "sometimes", "never"], + defaultOption: "sometimes", +}; +const passwordQuestion: Question = { id: 1, type: QuestionType.withPassword }; +const luksActivationQuestion: Question = { id: 2, class: "storage.luks_activation" }; + +describe("Questions", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("when there are no pending questions", () => { + beforeEach(() => { + mockQuestions = []; + }); + + it("renders nothing", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when a question is answered", () => { + beforeEach(() => { + mockQuestions = [genericQuestion]; + }); + + it("triggers the useQuestionMutation", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Always" }); + await user.click(button); + expect(mockMutation).toHaveBeenCalledWith({ ...genericQuestion, answer: "always" }); + }); + }); + + describe("when there is a generic question pending", () => { + beforeEach(() => { + mockQuestions = [genericQuestion]; + // Not using jest.mock at the top like for the other question components + // because the original implementation was needed for testing that + // mutation is triggered when proceed. + jest + .spyOn(GenericQuestionComponent, "default") + .mockReturnValue(
A generic question mock
); + }); + + it("renders a GenericQuestion component", () => { + plainRender(); + screen.getByText("A generic question mock"); + }); + }); + + describe("when there is a generic question pending", () => { + beforeEach(() => { + mockQuestions = [passwordQuestion]; + }); + + it("renders a QuestionWithPassword component", () => { + plainRender(); + screen.getByText("A question with password mock"); + }); + }); + + describe("when there is a LUKS activation question pending", () => { + beforeEach(() => { + mockQuestions = [luksActivationQuestion]; + }); + + it("renders a LuksActivationQuestion component", () => { + installerRender(); + screen.getByText("A LUKS activation question mock"); + }); + }); +}); diff --git a/web/src/components/questions/Questions.tsx b/web/src/components/questions/Questions.tsx new file mode 100644 index 0000000000..42ef731d26 --- /dev/null +++ b/web/src/components/questions/Questions.tsx @@ -0,0 +1,56 @@ +/* + * Copyright (c) [2022-2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import GenericQuestion from "~/components/questions/GenericQuestion"; +import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; +import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; +import { useQuestions, useQuestionsConfig, useQuestionsChanges } from "~/queries/questions"; +import { AnswerCallback, QuestionType } from "~/types/questions"; + +export default function Questions(): React.ReactNode { + useQuestionsChanges(); + const pendingQuestions = useQuestions(); + const questionsConfig = useQuestionsConfig(); + + if (pendingQuestions.length === 0) return null; + + const answerQuestion: AnswerCallback = (answeredQuestion) => + questionsConfig.mutate(answeredQuestion); + + // Renders the first pending question + const [currentQuestion] = pendingQuestions; + + let QuestionComponent = GenericQuestion; + + // show specialized popup for question which need password + if (currentQuestion.type === QuestionType.withPassword) { + QuestionComponent = QuestionWithPassword; + } + + // show specialized popup for luks activation question + // more can follow as it will be needed + if (currentQuestion.class === "storage.luks_activation") { + QuestionComponent = LuksActivationQuestion; + } + + return ; +} diff --git a/web/src/components/questions/index.js b/web/src/components/questions/index.ts similarity index 71% rename from web/src/components/questions/index.js rename to web/src/components/questions/index.ts index 15cc7dee28..95dcc90e28 100644 --- a/web/src/components/questions/index.js +++ b/web/src/components/questions/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -19,8 +19,4 @@ * find current contact information at www.suse.com. */ -export { default as QuestionActions } from "./QuestionActions"; -export { default as GenericQuestion } from "./GenericQuestion"; -export { default as QuestionWithPassword } from "./QuestionWithPassword"; -export { default as LuksActivationQuestion } from "./LuksActivationQuestion"; export { default as Questions } from "./Questions"; diff --git a/web/src/queries/questions.ts b/web/src/queries/questions.ts new file mode 100644 index 0000000000..f5b56f41b9 --- /dev/null +++ b/web/src/queries/questions.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { Answer, Question, QuestionType } from "~/types/questions"; + +type APIQuestion = { + generic?: Question; + withPassword?: Pick; +}; + +/** + * Internal method to build proper question objects + * + * TODO: improve/simplify it once the backend API is improved. + */ +function buildQuestion(httpQuestion: APIQuestion) { + const question: Question = { ...httpQuestion.generic }; + + if (httpQuestion.generic) { + question.type = QuestionType.generic; + question.answer = httpQuestion.generic.answer; + } + + if (httpQuestion.withPassword) { + question.type = QuestionType.withPassword; + question.password = httpQuestion.withPassword.password; + } + + return question; +} + +/** + * Query to retrieve questions + */ +const questionsQuery = () => ({ + queryKey: ["questions"], + queryFn: () => fetch("/api/questions").then((res) => res.json()), +}); + +/** + * Hook that builds a mutation given question, allowing to answer it + + * TODO: improve/simplify it once the backend API is improved. + */ +const useQuestionsConfig = () => { + const query = { + mutationFn: (question: Question) => { + const answer: Answer = { generic: { answer: question.answer } }; + + if (question.type === QuestionType.withPassword) { + answer.withPassword = { password: question.password }; + } + + return fetch(`/api/questions/${question.id}/answer`, { + method: "PUT", + body: JSON.stringify(answer), + headers: { + "Content-Type": "application/json", + }, + }); + }, + }; + return useMutation(query); +}; + +/** + * Hook for listening questions changes and performing proper invalidations + */ +const useQuestionsChanges = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + if (event.type === "QuestionsChanged") { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + } + }); + }, [client, queryClient]); +}; + +/** + * Hook for retrieving available questions + */ +const useQuestions = () => { + const { data, isPending } = useQuery(questionsQuery()); + return isPending ? [] : data.map(buildQuestion); +}; + +export { questionsQuery, useQuestions, useQuestionsConfig, useQuestionsChanges }; diff --git a/web/src/types/questions.ts b/web/src/types/questions.ts new file mode 100644 index 0000000000..85c88fe984 --- /dev/null +++ b/web/src/types/questions.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +/** + * Enum for question types + */ +enum QuestionType { + generic = "generic", + withPassword = "withPassword", +} + +type Question = { + id: number; + type?: QuestionType; + class?: string; + options?: string[]; + defaultOption?: string; + text?: string; + data?: { [key: string]: string }; + answer?: string; + password?: string; +}; + +type Answer = { + generic?: { answer: string }; + withPassword?: { password: string }; +}; + +type AnswerCallback = (answeredQuestion: Question) => void; + +export { QuestionType }; +export type { Answer, AnswerCallback, Question };