diff --git a/docs/plugin-system.md b/docs/plugin-system.md index ad1914b6fc66..a8e535d61be4 100644 --- a/docs/plugin-system.md +++ b/docs/plugin-system.md @@ -67,6 +67,7 @@ Communication between the parent page and the IFrame is restricted to specific m | `current-state` | IFrame | Parent | Informs the parent that the IFrame's state has changed. Includes data and validity status. | | `height-changed` | IFrame | Parent | Notifies the parent that the content height has changed, allowing the parent to resize the IFrame. | | `set-language` | Parent | IFrame | Informs the IFrame of the user's preferred language using IETF BCP 47 language tags. | +| `open-link` | Iframe | Parent | The IFrame requests a link to be opened in the browser's main browsing context. | ### Views diff --git a/services/quizzes/src/components/MarkdownEditor.tsx b/services/quizzes/src/components/MarkdownEditor.tsx deleted file mode 100644 index a11d20d2ea2b..000000000000 --- a/services/quizzes/src/components/MarkdownEditor.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { css } from "@emotion/css" -import styled from "@emotion/styled" -import React, { useEffect, useState } from "react" -import { useTranslation } from "react-i18next" - -import ParsedText from "./ParsedText" - -import Button from "@/shared-module/common/components/Button" -import TextField from "@/shared-module/common/components/InputFields/TextField" - -const EditorWrapper = styled.div` - width: 100%; - display: flex; - flex-direction: row; -` - -export interface MarkdownEditorProps { - onChange: (value: string, name?: string) => void - text: string - label: string -} - -export const MarkdownEditor: React.FC> = ({ - text, - label, - onChange, -}) => { - const { t } = useTranslation() - const [previewVisible, setPreviewVisible] = useState(false) - const [, setShowTabs] = useState(text.length > 0) - - useEffect(() => { - setShowTabs(text.length > 0) - }, [text]) - return ( - <> - - {!previewVisible && ( -
- -
- )} - {previewVisible && ( -
- -
- )} - -
- - ) -} - -export default MarkdownEditor diff --git a/services/quizzes/src/components/ParsedText/tagParser.ts b/services/quizzes/src/components/ParsedText/tagParser.ts index b259d280792f..cd31aa21f40b 100644 --- a/services/quizzes/src/components/ParsedText/tagParser.ts +++ b/services/quizzes/src/components/ParsedText/tagParser.ts @@ -6,7 +6,7 @@ const htmlWriter = new HtmlRenderer() const KATEX_OUTPUT_FORMAT = "htmlAndMathml" const LATEX_REGEX = /\[latex\](.*?)\[\/latex\]/g -const MARKDOWN_REGEX = /\[markdown\](.*?)\[\/markdown\]/g +const MARKDOWN_REGEX = /\[markdown\]([\s\S]*?)\[\/markdown\]/g type PairArray = [T, K][] /** diff --git a/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizCommonInfo.tsx b/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizCommonInfo.tsx index dd19b60ebc6a..5e3164c0b2e8 100644 --- a/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizCommonInfo.tsx +++ b/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizCommonInfo.tsx @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next" import { PrivateSpecQuiz } from "../../../../types/quizTypes/privateSpec" import useQuizzesExerciseServiceOutputState from "../../../hooks/useQuizzesExerciseServiceOutputState" -import MarkdownEditor from "../../MarkdownEditor" + +import ParsedTextField from "./QuizComponents/common/ParsedTextField" import Accordion from "@/shared-module/common/components/Accordion" import RadioButton from "@/shared-module/common/components/InputFields/RadioButton" @@ -65,9 +66,9 @@ const QuizCommonInfo: React.FC = () => {
{t("advanced-options")} - { updateState((draft) => { if (!draft) { diff --git a/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizComponents/common/ParsedTextField.tsx b/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizComponents/common/ParsedTextField.tsx index e1b72207bbdb..d1c6414a9e22 100644 --- a/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizComponents/common/ParsedTextField.tsx +++ b/services/quizzes/src/components/exercise-service-views/ExerciseEditor/QuizComponents/common/ParsedTextField.tsx @@ -1,13 +1,15 @@ -import { css } from "@emotion/css" import styled from "@emotion/styled" -import { Eye, Pencil } from "@vectopus/atlas-icons-react" -import React, { useState } from "react" +import { Eye, InfoCircle, Pencil } from "@vectopus/atlas-icons-react" +import React, { Ref, useContext, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import ParsedText from "../../../../ParsedText" +import MessagePortContext from "@/contexts/MessagePortContext" import Button from "@/shared-module/common/components/Button" +import TextAreaField from "@/shared-module/common/components/InputFields/TextAreaField" import TextField from "@/shared-module/common/components/InputFields/TextField" +import { OpenLinkMessage } from "@/shared-module/common/exercise-service-protocol-types" const DisplayContainer = styled.div` display: flex; @@ -27,16 +29,33 @@ const ParsedTextContainer = styled.div` height: 68px; ` -const LATEX_TAGS = /\[\/?latex\]/g -const MARKDOWN_TAGS = /\[\/?markdown\]/g +const StyledButton = styled(Button)` + display: flex !important; + align-items: center; + border: 1px solid #dae6e5 !important; + margin: 2px 0px 4px 0px !important; + height: 24px; + cursor: pointer; + :hover { + border: 1px solid #bcd1d0; + } +` -/** - * Checks if there exists tags such as [latex][/latex] or [markdown][/markdown] - * @param text Text to parse from - */ -const containsTags = (text: string) => { - return [...text.matchAll(LATEX_TAGS), ...text.matchAll(MARKDOWN_TAGS)].length > 0 -} +const TextfieldWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 7px; + align-items: center; +` + +const Grow = styled.div` + flex-grow: 1; +` + +const InfoLink = styled.a` + position: relative; + top: 13px; +` interface ParsedTextFieldProps { label: string @@ -46,81 +65,105 @@ interface ParsedTextFieldProps { const ParsedTextField: React.FC = ({ label, value, onChange }) => { const [preview, setPreview] = useState(false) - const [text, setText] = useState("") - - const hasTags = containsTags(text) + const [text, setText] = useState(value ?? "") + const cursorPosition = useRef(null) + const inputRef = useRef(null) + const messagePort = useContext(MessagePortContext) const { t } = useTranslation() - const PreviewButton = preview ? ( - <> - - -

{t("edit-text")}

- - ) : ( + const containsMarkdown = useMemo( + () => text.includes("[markdown]") && text.includes("[/markdown]"), + [text], + ) + + const prevContainsMarkdown = useRef(containsMarkdown) + + const containsLatex = useMemo(() => text.includes("[latex]") && text.includes("[/latex]"), [text]) + + const hasTags = useMemo( + () => containsMarkdown || containsLatex, + [containsMarkdown, containsLatex], + ) + + /** + * Handles focus and cursor position restoration when the `containsMarkdown` state changes. + */ + useEffect(() => { + if ( + (!prevContainsMarkdown.current && containsMarkdown) || + (prevContainsMarkdown.current && !containsMarkdown) + ) { + if (inputRef.current) { + inputRef.current.focus() + inputRef.current.setSelectionRange(cursorPosition.current, cursorPosition.current) + } + } + + prevContainsMarkdown.current = containsMarkdown + }, [containsMarkdown]) + + const PreviewButton = ( <> - -

{t("preview-rendered-text")}

+ setPreview(!preview)}> + {preview ? : } + +

{preview ? t("edit-text") : t("preview-rendered-text")}

) const handleOnChange = (value: string) => { + cursorPosition.current = inputRef.current?.selectionStart ?? null onChange(value) setText(value) } return ( - {preview ? ( - - - - ) : ( - handleOnChange(value)} - label={label} - /> - )} + + + {preview ? ( + + + + ) : containsMarkdown ? ( + } + autoResize + value={value ?? ""} + onChangeByValue={(value) => handleOnChange(value)} + label={label} + /> + ) : ( + } + value={value ?? ""} + onChangeByValue={(value) => handleOnChange(value)} + label={label} + /> + )} + + { + if (messagePort) { + const target = e.target as HTMLAnchorElement + messagePort.postMessage({ + message: "open-link", + data: + (target as HTMLAnchorElement).href || + (target.parentElement as HTMLAnchorElement)?.href, + } satisfies OpenLinkMessage) + } + }} + > + + + {hasTags && PreviewButton} ) diff --git a/services/quizzes/src/contexts/MessagePortContext.tsx b/services/quizzes/src/contexts/MessagePortContext.tsx new file mode 100644 index 000000000000..e2012e26cc48 --- /dev/null +++ b/services/quizzes/src/contexts/MessagePortContext.tsx @@ -0,0 +1,5 @@ +import { createContext } from "react" + +const MessagePortContext = createContext(null) + +export default MessagePortContext diff --git a/services/quizzes/src/pages/iframe.tsx b/services/quizzes/src/pages/iframe.tsx index f510edc9bc5a..ab1843ac7e44 100644 --- a/services/quizzes/src/pages/iframe.tsx +++ b/services/quizzes/src/pages/iframe.tsx @@ -21,6 +21,7 @@ import { migratePrivateSpecQuiz } from "../util/migration/privateSpecQuiz" import migratePublicSpecQuiz from "../util/migration/publicSpecQuiz" import migrateQuizAnswer from "../util/migration/userAnswerSpec" +import MessagePortContext from "@/contexts/MessagePortContext" import { StudentExerciseTaskSubmissionResult } from "@/shared-module/common/bindings" import HeightTrackingContainer from "@/shared-module/common/components/HeightTrackingContainer" import { @@ -209,9 +210,11 @@ const IFrame: React.FC> = () => { return ( -
- -
+ +
+ +
+
) } diff --git a/shared-module/packages/common/src/components/InputFields/TextField.tsx b/shared-module/packages/common/src/components/InputFields/TextField.tsx index 3ede7a106019..9a6bbfcd5fa3 100644 --- a/shared-module/packages/common/src/components/InputFields/TextField.tsx +++ b/shared-module/packages/common/src/components/InputFields/TextField.tsx @@ -60,7 +60,7 @@ export interface TextFieldProps extends InputHTMLAttributes { onChangeByValue?: (value: string, name?: string) => void } -const TextField: React.FC = forwardRef( +const TextField = forwardRef( ({ onChange, onChangeByValue, className, disabled, error, ...rest }: TextFieldProps, ref) => { const { t } = useTranslation() diff --git a/shared-module/packages/common/src/components/MessageChannelIFrame.tsx b/shared-module/packages/common/src/components/MessageChannelIFrame.tsx index b38e6ee2d6da..046c3f9b8be9 100644 --- a/shared-module/packages/common/src/components/MessageChannelIFrame.tsx +++ b/shared-module/packages/common/src/components/MessageChannelIFrame.tsx @@ -12,6 +12,7 @@ import { import { isHeightChangedMessage, isMessageFromIframe, + isOpenLinkMessage, } from "../exercise-service-protocol-types.guard" import useMessageChannel from "../hooks/useMessageChannel" @@ -75,6 +76,10 @@ const MessageChannelIFrame: React.FC< console.info("Updating height") // eslint-disable-next-line i18next/no-literal-string iframeRef.current.height = Number(data.data).toString() + "px" + } else if (isOpenLinkMessage(data)) { + console.info(`The iframe wants to open a link: ${data.data}`) + // eslint-disable-next-line i18next/no-literal-string + window.open(data.data, "_blank", "noopener,noreferrer") } else if (isMessageFromIframe(data)) { try { onMessageFromIframe(data, messageChannel.port1) diff --git a/shared-module/packages/common/src/exercise-service-protocol-types.guard.ts b/shared-module/packages/common/src/exercise-service-protocol-types.guard.ts index f873126bebcb..b5b0e6d4c2e7 100644 --- a/shared-module/packages/common/src/exercise-service-protocol-types.guard.ts +++ b/shared-module/packages/common/src/exercise-service-protocol-types.guard.ts @@ -19,6 +19,7 @@ import { MessageToIframe, NonGenericGradingRequest, NonGenericGradingResult, + OpenLinkMessage, SetLanguageMessage, SetStateMessage, UploadResultMessage, @@ -54,6 +55,15 @@ export function isHeightChangedMessage(obj: unknown): obj is HeightChangedMessag ) } +export function isOpenLinkMessage(obj: unknown): obj is OpenLinkMessage { + const typedObj = obj as OpenLinkMessage + return ( + ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + typedObj["message"] === "open-link" && + typeof typedObj["data"] === "string" + ) +} + export function isFileUploadMessage(obj: unknown): obj is FileUploadMessage { const typedObj = obj as FileUploadMessage return ( diff --git a/shared-module/packages/common/src/exercise-service-protocol-types.ts b/shared-module/packages/common/src/exercise-service-protocol-types.ts index a03cf2487c0a..2a57fbf5c06e 100644 --- a/shared-module/packages/common/src/exercise-service-protocol-types.ts +++ b/shared-module/packages/common/src/exercise-service-protocol-types.ts @@ -21,6 +21,11 @@ export interface HeightChangedMessage { data: number } +export interface OpenLinkMessage { + message: "open-link" + data: string +} + export interface FileUploadMessage { message: "file-upload" files: Map diff --git a/shared-module/packages/common/src/locales/en/quizzes.json b/shared-module/packages/common/src/locales/en/quizzes.json index 5341bc2d04e3..44eff30037d5 100644 --- a/shared-module/packages/common/src/locales/en/quizzes.json +++ b/shared-module/packages/common/src/locales/en/quizzes.json @@ -47,6 +47,7 @@ "grading-strategy": "Grading strategy", "grant-only-when-fully-correct": "Grant only when fully correct", "grant-whenever-possible": "Grant whenever possible", + "hide-markdown-preview": "Hide markdown preview", "horizontal": "horizontal", "incorrect-option": "Incorrect", "input-header": "Input:", diff --git a/shared-module/packages/common/src/locales/uk/main-frontend.json b/shared-module/packages/common/src/locales/uk/main-frontend.json index e5fc3b862182..3b844d811936 100644 --- a/shared-module/packages/common/src/locales/uk/main-frontend.json +++ b/shared-module/packages/common/src/locales/uk/main-frontend.json @@ -564,7 +564,7 @@ "reprocess-module-completions": "Повторно обробити завершення модулів", "required-field": "Це поле є обов'язковим", "requires-a-finnish-social-security-number": "(Потрібен фінський номер соціального страхування)", - "research-consent-data-from-learning-process-is-used": "У цьому дослідженні використовуються дані процесу навчання та дані, які ви надаєте нам через анкети. Дані включають інформацію про використання навчальних матеріалів, хід виконання та виконання завдань курсу та успішність іспиту. Окремих студентів неможливо ідентифікувати за опублікованими результатами. Участь є добровільною, і якщо ви не бажаєте брати участь у дослідженні, для вас не буде жодних наслідків.", + "research-consent-data-from-learning-process-is-used": "У цьому дослідженні використовуються дані процесу навчання та дані, які ви надаєте нам через анкети. Дані включають інформацію про використання навчальних матеріалів, прогрес та виконання завдань курсу та успішність іспиту. Окремих студентів неможливо ідентифікувати за опублікованими результатами. Участь є добровільною, і якщо ви не бажаєте брати участь у дослідженні, для вас не буде жодних наслідків.", "research-consent-educational-research-is-conducted-on-the-courses": "На курсах проводяться навчальні дослідження. Це дослідження має кілька цілей:", "research-consent-goals-advance-knowledge": "поглибити знання та розуміння навчання в онлайн-навчальних середовищах, а також", "research-consent-goals-develop-learning": "розробляти навчальні матеріали таким чином, щоб вони враховували індивідуальні відмінності в навчанні та могли індивідуалізувати зміст матеріалу залежно від учня,", diff --git a/shared-module/packages/common/src/utils/responseHeaders.js b/shared-module/packages/common/src/utils/responseHeaders.js index e7a341e95769..2bfe4e709718 100644 --- a/shared-module/packages/common/src/utils/responseHeaders.js +++ b/shared-module/packages/common/src/utils/responseHeaders.js @@ -16,7 +16,10 @@ function generateNormalResponseHeaders(options = { requireTrustedTypesFor: false { key: "Permissions-Policy", - value: [`fullscreen=(self "https://www.thinglink.com")`, `encrypted-media=*`] + value: [ + `fullscreen=(self "https://www.thinglink.com" "https://*.mooc.fi")`, + `encrypted-media=*`, + ] .filter((o) => !!o) .join(", "), }, diff --git a/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-desktop-regular.png b/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-desktop-regular.png index 38df7083b54c..045d1bcb7f27 100644 Binary files a/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-desktop-regular.png and b/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-mobile-tall.png b/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-mobile-tall.png index 690eb674094a..68573d2528df 100644 Binary files a/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-mobile-tall.png and b/system-tests/src/__screenshots__/course-instance-management/course-instance-management.spec.ts/management-page-after-changes-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-creating-new-module-mobile-tall.png b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-creating-new-module-mobile-tall.png index 4f7d2ace691f..c6c297971f39 100644 Binary files a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-creating-new-module-mobile-tall.png and b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-creating-new-module-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-last-update-mobile-tall.png b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-last-update-mobile-tall.png index 813b4dee7b25..d6eb4ad6cdd9 100644 Binary files a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-last-update-mobile-tall.png and b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-last-update-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-second-deletion-mobile-tall.png b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-second-deletion-mobile-tall.png index ce20910d6663..45c55231e78f 100644 Binary files a/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-second-deletion-mobile-tall.png and b/system-tests/src/__screenshots__/course-modules/course-modules.spec.ts/after-second-deletion-mobile-tall.png differ diff --git a/system-tests/src/tests/quizzes/create-and-save-quizzes.spec.ts b/system-tests/src/tests/quizzes/create-and-save-quizzes.spec.ts index 3666a60a368b..704aac41fa10 100644 --- a/system-tests/src/tests/quizzes/create-and-save-quizzes.spec.ts +++ b/system-tests/src/tests/quizzes/create-and-save-quizzes.spec.ts @@ -236,23 +236,21 @@ const createChooseN = async (frame: Locator) => { await frame.getByRole("button", { name: "Add option" }).click() await frame.getByLabel("Choices (N)", { exact: true }).click() await frame.getByLabel("Choices (N)", { exact: true }).fill("2") - await frame - .getByRole("group") - .filter({ hasText: "Advanced options Feedback message Success message Failure message" }) - .locator("summary") - .click() - await frame - .getByRole("group") - .filter({ hasText: "Advanced options Feedback message Success message Failure message" }) - .getByLabel("Success message", { exact: true }) - .click() - await frame - .getByRole("group") - .filter({ hasText: "Advanced options Feedback message Success message Failure message" }) + + const advancedOptionsAccordion = frame + .locator("details") + .filter({ hasText: "Advanced options" }) + .first() + await advancedOptionsAccordion.locator("summary").click() + await scrollElementInsideIframeToView(advancedOptionsAccordion) + await advancedOptionsAccordion.getByLabel("Success message", { exact: true }).click() + await advancedOptionsAccordion .getByLabel("Success message", { exact: true }) .fill("Success message for feedback") - await frame.getByLabel("Failure message", { exact: true }).click() - await frame.getByLabel("Failure message", { exact: true }).fill("Failure message for feedback") + await advancedOptionsAccordion.getByLabel("Failure message", { exact: true }).click() + await advancedOptionsAccordion + .getByLabel("Failure message", { exact: true }) + .fill("Failure message for feedback") }) }