diff --git a/services/cms/src/blocks/ConditionalBlock/ConditionalBlockEditor.tsx b/services/cms/src/blocks/ConditionalBlock/ConditionalBlockEditor.tsx index 8de03221a8a7..edf82b1611b1 100644 --- a/services/cms/src/blocks/ConditionalBlock/ConditionalBlockEditor.tsx +++ b/services/cms/src/blocks/ConditionalBlock/ConditionalBlockEditor.tsx @@ -22,6 +22,43 @@ const ALLOWED_NESTED_BLOCKS = [ "core/button", "core/paragraph", "moocfi/exercise-custom-view-block", + "moocfi/aside", + "moocfi/chapter-progress", + "moocfi/congratulations", + "moocfi/course-chapter-grid", + "moocfi/course-progress", + "moocfi/exercise-slide", + "moocfi/exercise-task", + "moocfi/exercise-slides", + "moocfi/exercise-settings", + "moocfi/exercises-in-chapter", + "moocfi/glossary", + "moocfi/infobox", + "moocfi/latex", + "moocfi/learning-objectives", + "moocfi/pages-in-chapter", + "moocfi/unsupported-block-type", + "moocfi/highlightbox", + "moocfi/instructionbox", + "moocfi/tablebox", + "moocfi/iframe", + "moocfi/map", + "moocfi/author", + "moocfi/author-inner-block", + "moocfi/conditional-block", + "moocfi/exercise-custom-view-block", + "moocfi/top-level-pages", + "moocfi/expandable-content", + "moocfi/expandable-content-inner-block", + "moocfi/revelable-content", + "moocfi/revealable-hidden-content", + "moocfi/aside-with-image", + "moocfi/flip-card", + "moocfi/front-card", + "moocfi/back-card", + "moocfi/code-giveaway", + "moocfi/ingress", + "moocfi/terminology-block", ] const Wrapper = styled.div` diff --git a/services/cms/src/blocks/Partners/PartnersEditor.tsx b/services/cms/src/blocks/Partners/PartnersEditor.tsx deleted file mode 100644 index 4a877d87301d..000000000000 --- a/services/cms/src/blocks/Partners/PartnersEditor.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { css } from "@emotion/css" -import { InnerBlocks } from "@wordpress/block-editor" -import { BlockEditProps } from "@wordpress/blocks" -import React from "react" -import { useTranslation } from "react-i18next" - -import BlockWrapper from "../BlockWrapper" - -import { baseTheme, headingFont } from "@/shared-module/common/styles" - -const ALLOWED_NESTED_BLOCKS = ["core/image"] - -const SponsorEditor: React.FC>>> = ({ - clientId, -}) => { - const { t } = useTranslation() - return ( - -
-
-

{t("partners-block")}

- - {t("partners-block-description")} - -
-
- -
-
-
- ) -} - -export default SponsorEditor diff --git a/services/cms/src/blocks/Partners/PartnersSave.tsx b/services/cms/src/blocks/Partners/PartnersSave.tsx deleted file mode 100644 index 74c6093a6192..000000000000 --- a/services/cms/src/blocks/Partners/PartnersSave.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { InnerBlocks } from "@wordpress/block-editor" - -const SponsorSave: React.FC = () => { - return ( -
- -
- ) -} - -export default SponsorSave diff --git a/services/cms/src/blocks/Partners/index.tsx b/services/cms/src/blocks/Partners/index.tsx deleted file mode 100644 index 723d736e0fc5..000000000000 --- a/services/cms/src/blocks/Partners/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -import { BlockConfiguration } from "@wordpress/blocks" - -import { MOOCFI_CATEGORY_SLUG } from "../../utils/Gutenberg/modifyGutenbergCategories" - -import SponsorEditor from "./PartnersEditor" -import SponsorSave from "./PartnersSave" - -const SponsorConfiguration: BlockConfiguration = { - title: "Sponsor Section", - description: "Sponsor Section", - category: MOOCFI_CATEGORY_SLUG, - attributes: {}, - edit: SponsorEditor, - save: SponsorSave, -} - -export default SponsorConfiguration diff --git a/services/cms/src/blocks/index.tsx b/services/cms/src/blocks/index.tsx index 9ecfbbdf8649..d9fdbb706475 100644 --- a/services/cms/src/blocks/index.tsx +++ b/services/cms/src/blocks/index.tsx @@ -35,10 +35,8 @@ import LandingPageCopyText from "./LandingPageCopyText" import LandingPageHeroSection from "./LandingPageHeroSection" import Latex from "./Latex" import LearningObjectives from "./LearningObjectives" -// import LogoLink from "./LogoLink" import Map from "./Map" import PagesInChapter from "./PagesInChapter" -import PartnersBlock from "./Partners" import ResearchFormQuestion from "./ResearchConsentQuestion" import RevealableContent from "./RevealableContent" import RevealableHiddenContent from "./RevealableContent//RevealableHiddenContent" @@ -78,7 +76,6 @@ export const blockTypeMapForPages = [ ["moocfi/author-inner-block", AuthorInnerBlock], ["moocfi/conditional-block", ConditionalBlock], ["moocfi/exercise-custom-view-block", ExerciseCustomView], - ["moocfi/partners", PartnersBlock], ["moocfi/top-level-pages", TopLevelPage], ["moocfi/expandable-content", ExpendableContent], ["moocfi/expandable-content-inner-block", ExpendableContentInnerBlock], @@ -105,7 +102,6 @@ export const blockTypeMapForFrontPages = [ ["moocfi/glossary", Glossary], ["moocfi/landing-page-hero-section", LandingPageHeroSection], ["moocfi/latex", Latex], - ["moocfi/partners", PartnersBlock], ["moocfi/top-level-pages", TopLevelPage], ["moocfi/unsupported-block-type", UnsupportedBlock], ["moocfi/landing-page-copy-text", LandingPageCopyText], @@ -114,6 +110,8 @@ export const blockTypeMapForFrontPages = [ ["moocfi/conditional-block", ConditionalBlock], ["moocfi/exercise-custom-view-block", ExerciseCustomView], ["moocfi/code-giveaway", CodeGiveaway], + ["moocfi/expandable-content", ExpendableContent], + ["moocfi/expandable-content-inner-block", ExpendableContentInnerBlock], // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as Array<[string, BlockConfiguration>]> diff --git a/services/cms/src/blocks/supportedGutenbergBlocks.ts b/services/cms/src/blocks/supportedGutenbergBlocks.ts index 0ca84014bd05..8ee1cfcba827 100644 --- a/services/cms/src/blocks/supportedGutenbergBlocks.ts +++ b/services/cms/src/blocks/supportedGutenbergBlocks.ts @@ -53,6 +53,8 @@ export const allowedEmailCoreBlocks: string[] = [ "core/table", ] +export const allowedPartnerCoreBlocks: string[] = ["core/image"] + export const allowedExamInstructionsCoreBlocks: string[] = [ "core/paragraph", "core/image", diff --git a/services/cms/src/components/editors/PartnersBlockEditor.tsx b/services/cms/src/components/editors/PartnersBlockEditor.tsx new file mode 100644 index 000000000000..726bea91a2c1 --- /dev/null +++ b/services/cms/src/components/editors/PartnersBlockEditor.tsx @@ -0,0 +1,88 @@ +/* eslint-disable i18next/no-literal-string */ +import { css } from "@emotion/css" +import { BlockInstance } from "@wordpress/blocks" +import dynamic from "next/dynamic" +import React, { useContext, useState } from "react" +import { useTranslation } from "react-i18next" + +import { allowedPartnerCoreBlocks } from "../../blocks/supportedGutenbergBlocks" +import CourseContext from "../../contexts/CourseContext" +import mediaUploadBuilder from "../../services/backend/media/mediaUpload" +import { modifyBlocks } from "../../utils/Gutenberg/modifyBlocks" + +import { PartnersBlock } from "@/shared-module/common/bindings" +import Button from "@/shared-module/common/components/Button" +import Spinner from "@/shared-module/common/components/Spinner" + +interface PartnersBlockEditorProps { + data: PartnersBlock + handleSave: (updatedTemplate: unknown) => Promise +} + +const EditorLoading = + +const PartnersBlockGutenbergEditor = dynamic(() => import("./GutenbergEditor"), { + ssr: false, + loading: () => EditorLoading, +}) + +const PartnersSectionEditor: React.FC> = ({ + data, + handleSave, +}) => { + const courseId = useContext(CourseContext)?.courseId + const { t } = useTranslation() + const [content, setContent] = useState( + modifyBlocks( + (data.content ?? []) as BlockInstance[], + allowedPartnerCoreBlocks, + ) as BlockInstance[], + ) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleOnSave = async () => { + setSaving(true) + try { + const res = await handleSave(content) + setContent(res.content as BlockInstance[]) + setError(null) + } catch (e: unknown) { + if (!(e instanceof Error)) { + throw e + } + setError(e.toString()) + } finally { + setSaving(false) + } + } + + return ( + <> +
+
+ {error &&
{error}
} + +
+
+ + {courseId && ( + {}} + /> + )} + + ) +} +export default PartnersSectionEditor diff --git a/services/cms/src/pages/partners-block/[id]/edit.tsx b/services/cms/src/pages/partners-block/[id]/edit.tsx new file mode 100644 index 000000000000..d2aaf9d1ed2a --- /dev/null +++ b/services/cms/src/pages/partners-block/[id]/edit.tsx @@ -0,0 +1,66 @@ +import dynamic from "next/dynamic" +import React from "react" + +import CourseContext from "../../../contexts/CourseContext" + +import { fetchPartnersBlock, setPartnerBlockForCourse } from "@/services/backend/partners-block" +import { PartnersBlock } from "@/shared-module/common/bindings" +import ErrorBanner from "@/shared-module/common/components/ErrorBanner" +import Spinner from "@/shared-module/common/components/Spinner" +import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext" +import useStateQuery from "@/shared-module/common/hooks/useStateQuery" +import dontRenderUntilQueryParametersReady, { + SimplifiedUrlQuery, +} from "@/shared-module/common/utils/dontRenderUntilQueryParametersReady" +import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary" + +const EditorLoading = + +const PartnersBlockEditor = dynamic( + () => import("../../../components/editors/PartnersBlockEditor"), + { + ssr: false, + loading: () => EditorLoading, + }, +) + +export interface PartnersBlockProps { + query: SimplifiedUrlQuery<"id"> +} + +const PartnersBlockEdit: React.FC> = ({ query }) => { + // const [needToRunMigrationsAndValidations, setNeedToRunMigrationsAndValidations] = useState(false) + const courseId = query.id + // eslint-disable-next-line i18next/no-literal-string + const blockQuery = useStateQuery(["partners-block", courseId], (courseId) => + fetchPartnersBlock(courseId), + ) + + if (blockQuery.state === "error") { + return ( + <> + + + ) + } + + if (blockQuery.state !== "ready") { + return + } + + const handleSave = async (data: unknown): Promise => { + const res = await setPartnerBlockForCourse(courseId, data ?? []) + await blockQuery.refetch() + return res + } + + return ( + + + + ) +} + +export default withErrorBoundary( + withSignedIn(dontRenderUntilQueryParametersReady(PartnersBlockEdit)), +) diff --git a/services/cms/src/services/backend/partners-block.ts b/services/cms/src/services/backend/partners-block.ts new file mode 100644 index 000000000000..011ed7a4dcf2 --- /dev/null +++ b/services/cms/src/services/backend/partners-block.ts @@ -0,0 +1,22 @@ +import { cmsClient } from "./cmsClient" + +import { PartnersBlock } from "@/shared-module/common/bindings" +import { isPartnersBlock } from "@/shared-module/common/bindings.guard" +import { validateResponse } from "@/shared-module/common/utils/fetching" + +export const setPartnerBlockForCourse = async ( + courseId: string, + data: object | null, +): Promise => { + const response = await cmsClient.post(`/courses/${courseId}/partners-block`, data) + return validateResponse(response, isPartnersBlock) +} + +export const fetchPartnersBlock = async (courseId: string): Promise => { + const response = await cmsClient.get(`/courses/${courseId}/partners-block`) + return validateResponse(response, isPartnersBlock) +} + +export const deletePartnersBlock = async (courseId: string): Promise => { + await cmsClient.delete(`/courses/${courseId}/partners-block`) +} diff --git a/services/course-material/src/components/ContentRenderer/index.tsx b/services/course-material/src/components/ContentRenderer/index.tsx index a974ade46fb6..21a715cf85fa 100644 --- a/services/course-material/src/components/ContentRenderer/index.tsx +++ b/services/course-material/src/components/ContentRenderer/index.tsx @@ -64,7 +64,6 @@ import LearningObjectiveBlock from "./moocfi/LearningObjectiveBlock" import LogoLink from "./moocfi/LogoLink" import Map from "./moocfi/Map" import PagesInChapterBlock from "./moocfi/PagesInChapterBlock" -import PartnersBlock from "./moocfi/PartnersBlock" import ResearchConsentQuestionBlock from "./moocfi/ResearchConsentQuestionBlock" import RevealableContentBlock from "./moocfi/RevealableContentBlock/RevealableContentBlock" import RevealableHiddenContentBlock from "./moocfi/RevealableContentBlock/RevealableHiddenContentBlock" @@ -157,7 +156,6 @@ export const blockToRendererMap: { [blockName: string]: any } = { "moocfi/latex": LatexBlock, "moocfi/learning-objectives": LearningObjectiveBlock, "moocfi/pages-in-chapter": PagesInChapterBlock, - "moocfi/partners": PartnersBlock, "moocfi/highlightbox": HighlightBox, "moocfi/instructionbox": InstructionBoxBlock, "moocfi/tablebox": TableBox, diff --git a/services/course-material/src/components/layout/DynamicSvg.tsx b/services/course-material/src/components/layout/DynamicSvg.tsx new file mode 100644 index 000000000000..8cf1aa5fbf40 --- /dev/null +++ b/services/course-material/src/components/layout/DynamicSvg.tsx @@ -0,0 +1,51 @@ +import { css } from "@emotion/css" +import React, { useEffect, useState } from "react" + +import { baseTheme } from "@/shared-module/common/styles" + +interface DynamicSvgProps { + src: string // URL of the SVG +} + +const DynamicSvg: React.FC = ({ src }) => { + const [svgContent, setSvgContent] = useState(null) + + useEffect(() => { + const fetchSvg = async () => { + try { + const response = await fetch(src) + if (!response.ok) { + throw new Error(`Failed to fetch SVG: ${response.statusText}`) + } + const text = await response.text() + setSvgContent(text) + } catch (error) { + console.error("Error fetching SVG:", error) + } + } + + fetchSvg() + }, [src]) + + if (!svgContent) { + return

Loading SVG...

+ } + + return ( +
+ ) +} + +export default DynamicSvg diff --git a/services/course-material/src/components/layout/Layout.tsx b/services/course-material/src/components/layout/Layout.tsx index 9f5576859b9b..c5d3adf2a2ac 100644 --- a/services/course-material/src/components/layout/Layout.tsx +++ b/services/course-material/src/components/layout/Layout.tsx @@ -13,6 +13,7 @@ import SearchDialog from "../SearchDialog" import { useFigureOutNewUrl } from "../modals/ChooseCourseLanguage" import UserNavigationControls from "../navigation/UserNavigationControls" +import PartnersSectionBlock from "./PartnersSection" import ScrollIndicator from "./ScrollIndicator" import Centered from "@/shared-module/common/components/Centering/Centered" @@ -183,6 +184,7 @@ const Layout: React.FC> = ({ children }) => `} > +
diff --git a/services/course-material/src/components/layout/PartnersSection.tsx b/services/course-material/src/components/layout/PartnersSection.tsx new file mode 100644 index 000000000000..40ad87103388 --- /dev/null +++ b/services/course-material/src/components/layout/PartnersSection.tsx @@ -0,0 +1,94 @@ +import { css } from "@emotion/css" +import { useQuery } from "@tanstack/react-query" +import React from "react" + +import DynamicSvg from "./DynamicSvg" + +import { fetchPartnersBlock } from "@/services/backend" + +interface PartnersBlockProps { + courseId: string | null +} + +const PartnersSectionBlock: React.FC = ({ courseId }) => { + const getPartnersBlock = useQuery({ + queryKey: ["partners-block", courseId], + queryFn: () => fetchPartnersBlock(courseId as NonNullable), + }) + + const content = + getPartnersBlock.isSuccess && Array.isArray(getPartnersBlock.data.content) + ? getPartnersBlock.data.content + : [] // Default to an empty array if content is not present + + const hasImages = content.some((block) => block.name === "core/image" && block.attributes.url) + + return ( + <> + {hasImages && ( +
+ {content.map((block) => { + if (block.name === "core/image" && block.attributes.url) { + const { url, alt, href, linkDestination } = block.attributes + + // Ensure that the link is always a full URL (https://) + // eslint-disable-next-line i18next/no-literal-string + const formattedLink = href && !/^https?:\/\//i.test(href) ? `https://${href}` : href + const isSvgUrl = url.endsWith(".svg") + + // Conditionally return image wrapped in a link or just the image based on whether 'link' is available + return linkDestination == "custom" ? ( + + {isSvgUrl ? ( + + ) : ( +
+ {alt} +
+ )} +
+ ) : isSvgUrl ? ( + + ) : ( +
+ {alt} +
+ ) + } + })} +
+ )} + + ) +} + +export default PartnersSectionBlock diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index cb0be1a97c4c..893394aed68b 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -34,6 +34,7 @@ import { PageNavigationInformation, PageSearchResult, PageWithExercises, + PartnersBlock, PeerOrSelfReviewsReceived, ResearchForm, ResearchFormQuestion, @@ -75,6 +76,7 @@ import { isPageNavigationInformation, isPageSearchResult, isPageWithExercises, + isPartnersBlock, isPeerOrSelfReviewsReceived, isResearchForm, isResearchFormQuestion, @@ -743,3 +745,8 @@ export const claimCodeFromCodeGiveaway = async (id: string): Promise => const response = await courseMaterialClient.post(`/code-giveaways/${id}/claim`) return validateResponse(response, isString) } + +export const fetchPartnersBlock = async (courseId: string): Promise => { + const response = await courseMaterialClient.get(`/courses/${courseId}/partners-block`) + return validateResponse(response, isPartnersBlock) +} diff --git a/services/headless-lms/migrations/20241112104854_create_partners_blocks.down.sql b/services/headless-lms/migrations/20241112104854_create_partners_blocks.down.sql new file mode 100644 index 000000000000..8a5154e79a4f --- /dev/null +++ b/services/headless-lms/migrations/20241112104854_create_partners_blocks.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE partners_blocks; diff --git a/services/headless-lms/migrations/20241112104854_create_partners_blocks.up.sql b/services/headless-lms/migrations/20241112104854_create_partners_blocks.up.sql new file mode 100644 index 000000000000..ba62bbc3753a --- /dev/null +++ b/services/headless-lms/migrations/20241112104854_create_partners_blocks.up.sql @@ -0,0 +1,20 @@ +-- Add up migration script here +CREATE TABLE partners_blocks ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + content JSONB, + course_id UUID NOT NULL REFERENCES courses(id), + CONSTRAINT unique_course_id UNIQUE (course_id) +); + +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON partners_blocks FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); +COMMENT ON TABLE partners_blocks IS 'A partners block is a custom content block displayed across all pages of a course, positioned directly above the site footer. This block showcases partner logos and links, providing easy access to relevant partner sites. The partners_blocks table stores the content data for this block. Content is created and managed through the Gutenberg Editor.'; +COMMENT ON COLUMN partners_blocks.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN partners_blocks.created_at IS 'Timestamp of when the record was created'; +COMMENT ON COLUMN partners_blocks.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN partners_blocks.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN partners_blocks.content IS 'The content of the partners block that is derived from the Gutenberg Editor'; +COMMENT ON COLUMN partners_blocks.course_id IS 'The course_id of the course the partners_block relates to.'; diff --git a/services/headless-lms/models/.sqlx/query-35b3b34cdc91dcd81c37e4c8c2539157d5bbf440edb85075cefc1f906e1a2122.json b/services/headless-lms/models/.sqlx/query-35b3b34cdc91dcd81c37e4c8c2539157d5bbf440edb85075cefc1f906e1a2122.json new file mode 100644 index 000000000000..2ff7cc0231b7 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-35b3b34cdc91dcd81c37e4c8c2539157d5bbf440edb85075cefc1f906e1a2122.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE partners_blocks\nSET deleted_at = now()\nWHERE course_id = $1\nRETURNING *\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "content", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "course_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, true, true, false] + }, + "hash": "35b3b34cdc91dcd81c37e4c8c2539157d5bbf440edb85075cefc1f906e1a2122" +} diff --git a/services/headless-lms/models/.sqlx/query-879dbb664f2cbc283978c0423f18019600513dcd47aab2e9ddd2ba8a2b7661a2.json b/services/headless-lms/models/.sqlx/query-879dbb664f2cbc283978c0423f18019600513dcd47aab2e9ddd2ba8a2b7661a2.json new file mode 100644 index 000000000000..da261f9e9923 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-879dbb664f2cbc283978c0423f18019600513dcd47aab2e9ddd2ba8a2b7661a2.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT *\nFROM partners_blocks\nWHERE course_id = $1\n AND deleted_at IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "content", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "course_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, false, true, true, false] + }, + "hash": "879dbb664f2cbc283978c0423f18019600513dcd47aab2e9ddd2ba8a2b7661a2" +} diff --git a/services/headless-lms/models/.sqlx/query-9bbd67bd9326d6a5824cb6f6a084da3c5dbff670352ea2706ef89fa18578ca6c.json b/services/headless-lms/models/.sqlx/query-9bbd67bd9326d6a5824cb6f6a084da3c5dbff670352ea2706ef89fa18578ca6c.json new file mode 100644 index 000000000000..5c6cc0f9eadd --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-9bbd67bd9326d6a5824cb6f6a084da3c5dbff670352ea2706ef89fa18578ca6c.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 AS exists\n FROM partners_blocks\n WHERE course_id = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Int4" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [null] + }, + "hash": "9bbd67bd9326d6a5824cb6f6a084da3c5dbff670352ea2706ef89fa18578ca6c" +} diff --git a/services/headless-lms/models/.sqlx/query-f8de59ef7e3c893d2eb6331cb659037e037eef32a0683a5d9556f2bcff2c35df.json b/services/headless-lms/models/.sqlx/query-f8de59ef7e3c893d2eb6331cb659037e037eef32a0683a5d9556f2bcff2c35df.json new file mode 100644 index 000000000000..331682f90134 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-f8de59ef7e3c893d2eb6331cb659037e037eef32a0683a5d9556f2bcff2c35df.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO partners_blocks (course_id, content)\nVALUES ($1, $2)\nON CONFLICT (course_id)\nDO UPDATE\nSET content = EXCLUDED.content\nRETURNING *\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "content", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "course_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid", "Jsonb"] + }, + "nullable": [false, false, false, true, true, false] + }, + "hash": "f8de59ef7e3c893d2eb6331cb659037e037eef32a0683a5d9556f2bcff2c35df" +} diff --git a/services/headless-lms/models/src/lib.rs b/services/headless-lms/models/src/lib.rs index 27c65a6888a7..507e21d0cf26 100644 --- a/services/headless-lms/models/src/lib.rs +++ b/services/headless-lms/models/src/lib.rs @@ -61,6 +61,7 @@ pub mod page_visit_datum_summary_by_courses_countries; pub mod page_visit_datum_summary_by_courses_device_types; pub mod page_visit_datum_summary_by_pages; pub mod pages; +pub mod partner_block; pub mod peer_or_self_review_configs; pub mod peer_or_self_review_question_submissions; pub mod peer_or_self_review_questions; diff --git a/services/headless-lms/models/src/partner_block.rs b/services/headless-lms/models/src/partner_block.rs new file mode 100644 index 000000000000..5119564e0cc4 --- /dev/null +++ b/services/headless-lms/models/src/partner_block.rs @@ -0,0 +1,96 @@ +use crate::prelude::*; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct PartnersBlock { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + pub content: serde_json::Value, + pub course_id: Uuid, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct PartnerBlockNew { + pub course_id: Uuid, + pub content: Option, +} + +pub async fn upsert_partner_block( + conn: &mut PgConnection, + course_id: Uuid, + content: Option, +) -> ModelResult { + let res = sqlx::query_as!( + PartnersBlock, + r#" +INSERT INTO partners_blocks (course_id, content) +VALUES ($1, $2) +ON CONFLICT (course_id) +DO UPDATE +SET content = EXCLUDED.content +RETURNING * +"#, + course_id, + content, + ) + .fetch_one(conn) + .await?; + Ok(res) +} + +pub async fn get_partner_block( + conn: &mut PgConnection, + course_id: Uuid, +) -> ModelResult { + let res = sqlx::query_as!( + PartnersBlock, + "SELECT * +FROM partners_blocks +WHERE course_id = $1 + AND deleted_at IS NULL", + course_id + ) + .fetch_one(conn) + .await?; + Ok(res) +} + +pub async fn delete_partner_block( + conn: &mut PgConnection, + course_id: Uuid, +) -> ModelResult { + let deleted = sqlx::query_as!( + PartnersBlock, + r#" +UPDATE partners_blocks +SET deleted_at = now() +WHERE course_id = $1 +RETURNING * + "#, + course_id + ) + .fetch_one(conn) + .await?; + Ok(deleted) +} + +pub async fn check_if_course_exists( + conn: &mut PgConnection, + course_id: Uuid, +) -> Result { + let exists = sqlx::query!( + r#" + SELECT 1 AS exists + FROM partners_blocks + WHERE course_id = $1 + LIMIT 1 + "#, + course_id + ) + .fetch_optional(conn) + .await?; + Ok(exists.is_some()) +} diff --git a/services/headless-lms/server/src/controllers/cms/courses.rs b/services/headless-lms/server/src/controllers/cms/courses.rs index d7dcde5aad9f..bcacec5a114c 100644 --- a/services/headless-lms/server/src/controllers/cms/courses.rs +++ b/services/headless-lms/server/src/controllers/cms/courses.rs @@ -5,6 +5,7 @@ use crate::prelude::*; use models::{ course_instances::CourseInstance, pages::{Page, PageVisibility}, + partner_block::PartnersBlock, peer_or_self_review_configs::{self, CmsPeerOrSelfReviewConfiguration}, peer_or_self_review_questions::normalize_cms_peer_or_self_review_questions, }; @@ -234,6 +235,81 @@ async fn get_course_instances( token.authorized_ok(web::Json(instances)) } +/** + POST /api/v0/main-frontend/courses/:course_id/partners_block - Create or updates a partners block for a course +*/ +#[instrument(skip(payload, pool))] +async fn post_partners_block( + path: web::Path, + payload: web::Json>, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let course_id = path.into_inner(); + + let content = payload.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?; + + models::partner_block::upsert_partner_block(&mut conn, course_id, content).await?; + + token.authorized_ok(web::Json(())) +} + +/** +GET /courses/:course_id/partners_blocks - Gets a partners block related to a course +*/ +#[instrument(skip(pool))] +async fn get_partners_block( + path: web::Path, + user: AuthUser, + pool: web::Data, +) -> ControllerResult> { + let course_id = path.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?; + + // Check if the course exists in the partners_blocks table + let course_exists = models::partner_block::check_if_course_exists(&mut conn, course_id).await?; + + let partner_block = if course_exists { + // If the course exists, fetch the partner block + models::partner_block::get_partner_block(&mut conn, course_id).await? + } else { + // If the course does not exist, create a new partner block with an empty content array + let empty_content: Option = Some(serde_json::Value::Array(vec![])); + + // Upsert the partner block with the empty content + models::partner_block::upsert_partner_block(&mut conn, course_id, empty_content).await? + }; + + token.authorized_ok(web::Json(partner_block)) +} + +/** +DELETE `/api/v0/main-frontend/courses/:course_id` - Delete a partners block in a course. +*/ +#[instrument(skip(pool))] +async fn delete_partners_block( + path: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let course_id = path.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize( + &mut conn, + Act::UsuallyUnacceptableDeletion, + Some(user.id), + Res::Course(course_id), + ) + .await?; + let deleted_partners_block = + models::partner_block::delete_partner_block(&mut conn, course_id).await?; + + token.authorized_ok(web::Json(deleted_partners_block)) +} + /** Add a route for each controller in this module. @@ -264,6 +340,18 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{course_id}/research-consent-form", web::put().to(upsert_course_research_form), ) + .route( + "/{course_id}/partners-block", + web::post().to(post_partners_block), + ) + .route( + "/{course_id}/partners-block", + web::get().to(get_partners_block), + ) + .route( + "/{course_id}/partners-block", + web::delete().to(delete_partners_block), + ) .route("/{course_id}/modules", web::get().to(get_course_modules)) .route( "/{course_id}/course-instances", diff --git a/services/headless-lms/server/src/controllers/course_material/courses.rs b/services/headless-lms/server/src/controllers/course_material/courses.rs index d7da7ef6dae7..d5ca467bb82e 100644 --- a/services/headless-lms/server/src/controllers/course_material/courses.rs +++ b/services/headless-lms/server/src/controllers/course_material/courses.rs @@ -6,6 +6,7 @@ use actix_http::header::{self, X_FORWARDED_FOR}; use actix_web::web::Json; use chrono::Utc; use futures::{future::OptionFuture, FutureExt}; +use headless_lms_models::partner_block::PartnersBlock; use headless_lms_utils::ip_to_country::IpToCountryMapper; use isbot::Bots; use models::{ @@ -901,6 +902,23 @@ async fn get_research_form_answers_with_user_id( token.authorized_ok(web::Json(res)) } +/** +GET /courses/:course_id/partners_blocks - Gets a partners block related to a course +*/ +#[instrument(skip(pool))] +async fn get_partners_block( + path: web::Path, + user: AuthUser, + pool: web::Data, +) -> ControllerResult> { + let course_id = path.into_inner(); + let mut conn = pool.acquire().await?; + let token = skip_authorize(); + let partner_block = models::partner_block::get_partner_block(&mut conn, course_id).await?; + + token.authorized_ok(web::Json(partner_block)) +} + /** Add a route for each controller in this module. @@ -979,6 +997,10 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{course_id}/research-consent-form", web::get().to(get_research_form_with_course_id), ) + .route( + "/{course_id}/partners-block", + web::get().to(get_partners_block), + ) .route( "/{course_id}/research-consent-form-questions", web::get().to(get_research_form_questions_with_course_id), diff --git a/services/headless-lms/server/src/controllers/main_frontend/courses.rs b/services/headless-lms/server/src/controllers/main_frontend/courses.rs index 3263b5e5d981..5c28e0400a0b 100644 --- a/services/headless-lms/server/src/controllers/main_frontend/courses.rs +++ b/services/headless-lms/server/src/controllers/main_frontend/courses.rs @@ -2,7 +2,10 @@ use chrono::Utc; use domain::csv_export::user_exericse_states_export::UserExerciseStatesExportOperation; -use headless_lms_models::suspected_cheaters::{SuspectedCheaters, ThresholdData}; +use headless_lms_models::{ + partner_block::PartnersBlock, + suspected_cheaters::{SuspectedCheaters, ThresholdData}, +}; use rand::Rng; use std::sync::Arc; @@ -1513,6 +1516,82 @@ async fn get_course_with_join_code( token.authorized_ok(web::Json(course)) } +/** + POST /api/v0/main-frontend/courses/:course_id/partners_block - Create or updates a partners block for a course +*/ +#[instrument(skip(payload, pool))] +async fn post_partners_block( + path: web::Path, + payload: web::Json>, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let course_id = path.into_inner(); + + let content = payload.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?; + + let upserted_partner_block = + models::partner_block::upsert_partner_block(&mut conn, course_id, content).await?; + + token.authorized_ok(web::Json(upserted_partner_block)) +} + +/** +GET /courses/:course_id/partners_blocks - Gets a partners block related to a course +*/ +#[instrument(skip(pool))] +async fn get_partners_block( + path: web::Path, + user: AuthUser, + pool: web::Data, +) -> ControllerResult> { + let course_id = path.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?; + + // Check if the course exists in the partners_blocks table + let course_exists = models::partner_block::check_if_course_exists(&mut conn, course_id).await?; + + let partner_block = if course_exists { + // If the course exists, fetch the partner block + models::partner_block::get_partner_block(&mut conn, course_id).await? + } else { + // If the course does not exist, create a new partner block with an empty content array + let empty_content: Option = Some(serde_json::Value::Array(vec![])); + + // Upsert the partner block with the empty content + models::partner_block::upsert_partner_block(&mut conn, course_id, empty_content).await? + }; + + token.authorized_ok(web::Json(partner_block)) +} + +/** +DELETE `/api/v0/main-frontend/courses/:course_id` - Delete a partners block in a course. +*/ +#[instrument(skip(pool))] +async fn delete_partners_block( + path: web::Path, + pool: web::Data, + user: AuthUser, +) -> ControllerResult> { + let course_id = path.into_inner(); + let mut conn = pool.acquire().await?; + let token = authorize( + &mut conn, + Act::UsuallyUnacceptableDeletion, + Some(user.id), + Res::Course(course_id), + ) + .await?; + let deleted_partners_block = + models::partner_block::delete_partner_block(&mut conn, course_id).await?; + + token.authorized_ok(web::Json(deleted_partners_block)) +} + /** Add a route for each controller in this module. @@ -1687,6 +1766,18 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { "/{course_id}/join-course-with-join-code", web::post().to(add_user_to_course_with_join_code), ) + .route( + "/{course_id}/partners-block", + web::post().to(post_partners_block), + ) + .route( + "/{course_id}/partners-block", + web::get().to(get_partners_block), + ) + .route( + "/{course_id}/partners-block", + web::delete().to(delete_partners_block), + ) .route( "/{course_id}/set-join-code", web::post().to(set_join_code_for_course), diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 5d814c983bfe..288cbb5c440f 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -265,6 +265,8 @@ fn models(target: &mut File) { user_exercise_states::UserExerciseState, user_research_consents::UserResearchConsent, users::User, + partner_block::PartnersBlock, + partner_block::PartnerBlockNew, }; } diff --git a/services/main-frontend/src/components/page-specific/manage/courses/id/pages/ManageCourseStructure.tsx b/services/main-frontend/src/components/page-specific/manage/courses/id/pages/ManageCourseStructure.tsx index b24c5af49f12..42c99f0298e5 100644 --- a/services/main-frontend/src/components/page-specific/manage/courses/id/pages/ManageCourseStructure.tsx +++ b/services/main-frontend/src/components/page-specific/manage/courses/id/pages/ManageCourseStructure.tsx @@ -1,5 +1,6 @@ import { css, cx } from "@emotion/css" import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from "@tanstack/react-query" +import { BlockProhibited } from "@vectopus/atlas-icons-react" import { max } from "lodash" import React, { useEffect, useReducer, useState } from "react" import { useTranslation } from "react-i18next" @@ -114,6 +115,11 @@ const ManageCourseStructure: React.FC p.chapter_number)) + const openEditor = async () => { + // eslint-disable-next-line i18next/no-literal-string + window.location.assign(`/cms/partners-block/${courseStructure.course.id}/edit`) + } + return ( <>

@@ -345,6 +351,49 @@ const ManageCourseStructure: React.FC +
+ + + {t("partners-section-heading")} + +

{t("partners-section-text")}

+ +
+ => { await mainFrontendClient.post(`/courses/${courseId}/set-join-code`) } + +export const setPartnerBlockForCourse = async ( + courseId: string, + data: object | null, +): Promise => { + await mainFrontendClient.post(`/courses/${courseId}/partners-block`, data) +} + +export const fetchPartnersBlock = async (courseId: string): Promise => { + const response = await mainFrontendClient.get(`/courses/${courseId}/partners-block`) + return validateResponse(response, isPartnersBlock) +} + +export const deletePartnersBlock = async (courseId: string): Promise => { + await mainFrontendClient.delete(`/courses/${courseId}/partners-block`) +} diff --git a/services/main-frontend/src/utils/routing.ts b/services/main-frontend/src/utils/routing.ts index fdbff43a2f88..09862281a813 100644 --- a/services/main-frontend/src/utils/routing.ts +++ b/services/main-frontend/src/utils/routing.ts @@ -12,6 +12,10 @@ export function manageCourseInstanceEmailsPageRoute(courseInstanceId: string) { return `/manage/course-instances/${courseInstanceId}/emails` } +export function managePartnersBlockPageRoute(courseId: string) { + return `/manage/courses/${courseId}/partners-block` +} + export function manageCourseInstancePermissionsPageRoute(courseInstanceId: string) { return `/manage/course-instances/${courseInstanceId}/permissions` } diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index 924b9b6fb718..6928ebceb7ca 100644 --- a/shared-module/packages/common/src/bindings.guard.ts +++ b/shared-module/packages/common/src/bindings.guard.ts @@ -179,6 +179,8 @@ import { PageWithExercises, Pagination, PaperSize, + PartnerBlockNew, + PartnersBlock, PeerOrSelfReviewAnswer, PeerOrSelfReviewConfig, PeerOrSelfReviewQuestion, @@ -3415,6 +3417,26 @@ export function isUser(obj: unknown): obj is User { ) } +export function isPartnersBlock(obj: unknown): obj is PartnersBlock { + const typedObj = obj as PartnersBlock + return ( + ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + typeof typedObj["id"] === "string" && + typeof typedObj["created_at"] === "string" && + typeof typedObj["updated_at"] === "string" && + (typedObj["deleted_at"] === null || typeof typedObj["deleted_at"] === "string") && + typeof typedObj["course_id"] === "string" + ) +} + +export function isPartnerBlockNew(obj: unknown): obj is PartnerBlockNew { + const typedObj = obj as PartnerBlockNew + return ( + ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && + typeof typedObj["course_id"] === "string" + ) +} + export function isUploadResult(obj: unknown): obj is UploadResult { const typedObj = obj as UploadResult return ( diff --git a/shared-module/packages/common/src/bindings.ts b/shared-module/packages/common/src/bindings.ts index ff19bb6ef7f1..9f0060391aa8 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -1930,6 +1930,20 @@ export interface User { email_domain: string | null } +export interface PartnersBlock { + id: string + created_at: string + updated_at: string + deleted_at: string | null + content: unknown + course_id: string +} + +export interface PartnerBlockNew { + course_id: string + content: unknown | null +} + export interface UploadResult { url: string } diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 59129f106362..ac0d777a33d1 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -514,6 +514,9 @@ "page-number": "page {{page-number}}", "pages": "Pages", "paper-size": "Paper size", + "partners-section-button-text": "Add Partners Section", + "partners-section-heading": "Add Partner Logos Bar", + "partners-section-text": "This component is displayed just above the footer on all pages of the course, allows you to showcase logos of partner institutions or sponsors, each logo links to a different page.", "password": "Password", "password-must-have-at-least-8-characters": "Password must have at least 8 characters!", "passwords-dont-match": "Passwords don't match!", diff --git a/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-desktop-regular.png b/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-desktop-regular.png index 38efa7397237..71215afbe4a8 100644 Binary files a/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-desktop-regular.png and b/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-mobile-tall.png b/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-mobile-tall.png index 04f6f3dad1d6..c165ed942a85 100644 Binary files a/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-mobile-tall.png and b/system-tests/src/__screenshots__/change-course-language.spec.ts/course-lang-selection-eng-to-fi-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-desktop-regular.png b/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-desktop-regular.png index ae3a54c0e2d7..4ec8c8dabe7e 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-desktop-regular.png and b/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-mobile-tall.png b/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-mobile-tall.png index 09823a7054a5..10a09ab1e40e 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-mobile-tall.png and b/system-tests/src/__screenshots__/quizzes/feedback/multiple-choice-dropdown.spec.ts/multiple-choice-dropdown-feedback-incorrect-answer-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/open.spec.ts/open-feedback-incorrect-desktop-regular.png b/system-tests/src/__screenshots__/quizzes/feedback/open.spec.ts/open-feedback-incorrect-desktop-regular.png index e43e6d41cb4b..aa6081a66eac 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/open.spec.ts/open-feedback-incorrect-desktop-regular.png and b/system-tests/src/__screenshots__/quizzes/feedback/open.spec.ts/open-feedback-incorrect-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-desktop-regular.png b/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-desktop-regular.png index c316513056ba..dbbe6d3dca39 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-desktop-regular.png and b/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-desktop-regular.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-mobile-tall.png b/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-mobile-tall.png index 3a29852b8c3c..ae2fdc3391c4 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-mobile-tall.png and b/system-tests/src/__screenshots__/quizzes/feedback/timeline.spec.ts/timeline-initial-mobile-tall.png differ diff --git a/system-tests/src/__screenshots__/quizzes/feedback/vector.spec.ts/vector-initial-mobile-tall.png b/system-tests/src/__screenshots__/quizzes/feedback/vector.spec.ts/vector-initial-mobile-tall.png index 168ee68c885b..7d968b0265e2 100644 Binary files a/system-tests/src/__screenshots__/quizzes/feedback/vector.spec.ts/vector-initial-mobile-tall.png and b/system-tests/src/__screenshots__/quizzes/feedback/vector.spec.ts/vector-initial-mobile-tall.png differ diff --git a/system-tests/src/fixtures/media/sample-logo.svg b/system-tests/src/fixtures/media/sample-logo.svg new file mode 100644 index 000000000000..e757bc62abcc --- /dev/null +++ b/system-tests/src/fixtures/media/sample-logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/system-tests/src/tests/partners-block.spec.ts b/system-tests/src/tests/partners-block.spec.ts new file mode 100644 index 000000000000..f966fedaf3b7 --- /dev/null +++ b/system-tests/src/tests/partners-block.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test" + +import { selectCourseInstanceIfPrompted } from "@/utils/courseMaterialActions" + +test.use({ + storageState: "src/states/admin@example.com.json", +}) + +test("partner block tests", async ({ page }) => { + test.slow() + await page.goto("http://project-331.local/organizations") + + await Promise.all([ + page.getByText("University of Helsinki, Department of Mathematics and Statistics").click(), + ]) + + await page.getByLabel("Manage course 'Giveaway").click() + await page.getByRole("tab", { name: "Pages" }).click() + await page.getByText("Add Partners Section").click() + + await page.locator("button.components-button").click() + + await page.click('text="Image"') + + // Upload file with fileChooser + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.click('button:has-text("Upload")'), + ]) + await fileChooser.setFiles("src/fixtures/media/sample-logo.svg") + await page.getByRole("button", { name: "Save", exact: true }).click() + + await page.goto("http://project-331.local/org/uh-cs/courses/giveaway") + + await selectCourseInstanceIfPrompted(page) + + // Scroll and verify partners block + const partnersBlock = page.locator('[data-test-id="partners-block"]') + await partnersBlock.scrollIntoViewIfNeeded() + await expect(partnersBlock).toBeVisible() +})