Skip to content

Commit

Permalink
Privacy link + chapter progress (#1342)
Browse files Browse the repository at this point in the history
* create privacy_link migration file

* consume privacy-link endpoints

* add logic for chapter-progress at end of each chapter

* add successMessage to partnerBlockEditor

* add relevant files

* complete bundle features

* complete bundle features

* refactor code

* refactor code

* update description text

* remove unused code

* update snapshot test

* update snapshot test

* update snapshot test

* update snapshot test

* update snapshot test

* update snapshot test

* remove redundant code

* resolve review comments

* resolve review comments

* update snapshot test

* resolve review comments

* remove text-transform

* update snapshot test
  • Loading branch information
george-misan authored Dec 11, 2024
1 parent 10b199c commit d937f33
Show file tree
Hide file tree
Showing 59 changed files with 482 additions and 16 deletions.
15 changes: 14 additions & 1 deletion services/cms/src/components/editors/PartnersBlockEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { css } from "@emotion/css"
import { BlockInstance } from "@wordpress/blocks"
import dynamic from "next/dynamic"
import React, { useContext, useState } from "react"
import React, { useContext, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"

import { allowedPartnerCoreBlocks } from "../../blocks/supportedGutenbergBlocks"
Expand All @@ -12,6 +12,7 @@ import { modifyBlocks } from "../../utils/Gutenberg/modifyBlocks"

import { PartnersBlock } from "@/shared-module/common/bindings"
import Button from "@/shared-module/common/components/Button"
import SuccessNotification from "@/shared-module/common/components/Notifications/Success"
import Spinner from "@/shared-module/common/components/Spinner"

interface PartnersBlockEditorProps {
Expand Down Expand Up @@ -40,7 +41,9 @@ const PartnersSectionEditor: React.FC<React.PropsWithChildren<PartnersBlockEdito
)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)

//NB: refactor to use useToast
const handleOnSave = async () => {
setSaving(true)
try {
Expand All @@ -54,9 +57,18 @@ const PartnersSectionEditor: React.FC<React.PropsWithChildren<PartnersBlockEdito
setError(e.toString())
} finally {
setSaving(false)
setSuccessMessage(t("content-saved-successfully"))
}
}

// Hide success message after 3 seconds
useEffect(() => {
if (successMessage) {
const timeout = setTimeout(() => setSuccessMessage(null), 1000)
return () => clearTimeout(timeout) // Clear timeout if component unmounts
}
}, [successMessage])

return (
<>
<div className="editor__component">
Expand All @@ -82,6 +94,7 @@ const PartnersSectionEditor: React.FC<React.PropsWithChildren<PartnersBlockEdito
setNeedToRunMigrationsAndValidations={() => {}}
/>
)}
{successMessage && <SuccessNotification message={successMessage} />}
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import styled from "@emotion/styled"
import { useQuery } from "@tanstack/react-query"
import { differenceInSeconds, formatDuration, parseISO } from "date-fns"
import { i18n, TFunction } from "i18next"
import React, { useMemo } from "react"
import React, { useContext, useMemo } from "react"
import { useTranslation } from "react-i18next"

import PageContext from "../../../../contexts/PageContext"
import useTime from "../../../../hooks/useTime"
import { fetchPageNavigationData } from "../../../../services/backend"
import {
fetchPageNavigationData,
fetchUserChapterInstanceChapterProgress,
} from "../../../../services/backend"
import { courseFrontPageRoute, coursePageRoute } from "../../../../utils/routing"

import { PageNavigationInformation } from "@/shared-module/common/bindings"
Expand All @@ -14,6 +19,9 @@ import NextSectionLink, {
NextSectionLinkProps,
} from "@/shared-module/common/components/NextSectionLink"
import Spinner from "@/shared-module/common/components/Spinner"
import { monospaceFont } from "@/shared-module/common/styles"
import { respondToOrLarger } from "@/shared-module/common/styles/respond"
import { assertNotNullOrUndefined } from "@/shared-module/common/utils/nullability"

export interface NextPageProps {
chapterId: string | null
Expand All @@ -22,6 +30,59 @@ export interface NextPageProps {
organizationSlug: string
}

const ChapterProgress = styled.div`
background: #f4f6f8;
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
min-height: 6rem;
color: #1a2333;
margin-bottom: 1rem;
flex-direction: column;
${respondToOrLarger.md} {
flex-direction: row;
}
p {
margin-bottom: 0.4rem;
}
.progress-container {
display: flex;
align-items: end;
}
.metric {
font-family: ${monospaceFont};
font-size: 2rem;
font-weight: 700;
margin-right: 0.5rem;
line-height: 100%;
}
.attempted-exercises {
margin-right: 1.2rem;
}
.description {
opacity: 80%;
line-height: 100%;
align-self: end;
padding-top: 0.2rem;
}
.answers,
.attempted-exercises {
display: flex;
flex-direction: column;
${respondToOrLarger.md} {
flex-direction: row;
}
}
`

const NUMERIC = "numeric"
const LONG = "long"

Expand All @@ -33,12 +94,40 @@ const NextPage: React.FC<React.PropsWithChildren<NextPageProps>> = ({
}) => {
const { t, i18n } = useTranslation()
const now = useTime()
const pageContext = useContext(PageContext)
const courseInstanceId = pageContext.instance?.id

const getPageRoutingData = useQuery({
queryKey: [`pages-${chapterId}-page-routing-data`, currentPageId],
queryFn: () => fetchPageNavigationData(currentPageId),
})

// Compute `shouldFetchChapterProgress` inside `useMemo`
const shouldFetchChapterProgress = useMemo(
() => getPageRoutingData.data?.next_page?.chapter_id !== chapterId,
[getPageRoutingData.data, chapterId],
)

const getUserChapterProgress = useQuery({
queryKey: [`course-instance-${courseInstanceId}-chapter-${chapterId}-progress`],
queryFn: () =>
fetchUserChapterInstanceChapterProgress(
assertNotNullOrUndefined(courseInstanceId),
assertNotNullOrUndefined(chapterId),
),
enabled: shouldFetchChapterProgress,
})

const chapterProgress =
getUserChapterProgress.isSuccess && getUserChapterProgress.data
? {
maxScore: getUserChapterProgress.data.score_maximum,
givenScore: parseFloat(getUserChapterProgress.data.score_given.toFixed(2)),
attemptedExercises: getUserChapterProgress.data.attempted_exercises,
totalExercises: getUserChapterProgress.data.total_exercises,
}
: {}

const nextPageProps = useMemo(() => {
if (!getPageRoutingData.data) {
return null
Expand Down Expand Up @@ -71,9 +160,40 @@ const NextPage: React.FC<React.PropsWithChildren<NextPageProps>> = ({
return <Spinner variant={"medium"} />
}

function calculatePercentage(attempted: number, total: number): string {
if (total === 0) {
return "0%"
}
return Math.round((attempted / total) * 100) + "%"
}

return (
// Chapter exists, but next chapter not open yet.
<NextSectionLink {...nextPageProps} />
<>
{getPageRoutingData.data.next_page?.chapter_id !== chapterId && (
<ChapterProgress>
<p>{t("chapter-progress")}</p>
<div className="progress-container">
<div className="attempted-exercises">
<span className="metric">
{calculatePercentage(
chapterProgress.attemptedExercises ?? 0,
chapterProgress.totalExercises ?? 0,
)}
</span>
<span className="description">{t("attempted-exercises")}</span>
</div>
<div className="answers">
<span className="metric">
{chapterProgress.givenScore}/{chapterProgress.maxScore}
</span>
<span className="description">{t("points-label")}</span>
</div>
</div>
</ChapterProgress>
)}
<NextSectionLink {...nextPageProps} />
</>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import useQueryParameter from "@/shared-module/common/hooks/useQueryParameter"
import dontRenderUntilQueryParametersReady from "@/shared-module/common/utils/dontRenderUntilQueryParametersReady"
import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary"

const NavigationContainer: React.FC<React.PropsWithChildren<unknown>> = () => {
const NavigationContainer: React.FC<React.PropsWithChildren> = () => {
const pageContext = useContext(PageContext)
const courseSlug = useQueryParameter("courseSlug")
const organizationSlug = useQueryParameter("organizationSlug")
Expand Down
17 changes: 16 additions & 1 deletion services/course-material/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { css } from "@emotion/css"
import { useQuery } from "@tanstack/react-query"
import dynamic from "next/dynamic"
import Head from "next/head"
import { useRouter } from "next/router"
Expand All @@ -16,6 +17,7 @@ import UserNavigationControls from "../navigation/UserNavigationControls"
import PartnersSectionBlock from "./PartnersSection"
import ScrollIndicator from "./ScrollIndicator"

import { fetchPrivacyLink } from "@/services/backend"
import Centered from "@/shared-module/common/components/Centering/Centered"
import Footer from "@/shared-module/common/components/Footer"
import LanguageSelection, {
Expand Down Expand Up @@ -59,6 +61,19 @@ const Layout: React.FC<React.PropsWithChildren<LayoutProps>> = ({ children }) =>
null,
)

const getPrivacyLink = useQuery({
queryKey: ["privacy-link", courseId],
queryFn: () => fetchPrivacyLink(courseId as NonNullable<string>),
})

const customPrivacyLinks =
getPrivacyLink.isSuccess && Array.isArray(getPrivacyLink.data)
? getPrivacyLink.data.map((link) => ({
linkTitle: link.title,
linkUrl: link.url,
}))
: []

const languageVersions = useCourseLanguageVersions(courseId)
const languages: LanguageOption[] = (languageVersions?.data ?? []).map((languageVersion) => ({
tag: languageVersion.language_code,
Expand Down Expand Up @@ -185,7 +200,7 @@ const Layout: React.FC<React.PropsWithChildren<LayoutProps>> = ({ children }) =>
>
<DynamicToaster />
<PartnersSectionBlock courseId={courseId} />
<Footer />
<Footer privacyLinks={customPrivacyLinks} />
</div>
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ const PartnersSectionBlock: React.FC<PartnersBlockProps> = ({ courseId }) => {
row-gap: 2rem;
figure {
width: 5rem;
aspect-ratio: 1/1;
margin: 0;
img {
width: 12rem;
aspect-ratio: auto;
margin: 0 !important;
pointer-events: none;
}
Expand Down
7 changes: 7 additions & 0 deletions services/course-material/src/services/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
PageWithExercises,
PartnersBlock,
PeerOrSelfReviewsReceived,
PrivacyLink,
ResearchForm,
ResearchFormQuestion,
ResearchFormQuestionAnswer,
Expand Down Expand Up @@ -78,6 +79,7 @@ import {
isPageWithExercises,
isPartnersBlock,
isPeerOrSelfReviewsReceived,
isPrivacyLink,
isResearchForm,
isResearchFormQuestion,
isResearchFormQuestionAnswer,
Expand Down Expand Up @@ -750,3 +752,8 @@ export const fetchPartnersBlock = async (courseId: string): Promise<PartnersBloc
const response = await courseMaterialClient.get(`/courses/${courseId}/partners-block`)
return validateResponse(response, isPartnersBlock)
}

export const fetchPrivacyLink = async (courseId: string): Promise<PrivacyLink[]> => {
const response = await courseMaterialClient.get(`/courses/${courseId}/privacy-link`)
return validateResponse(response, isArray(isPrivacyLink))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE privacy_links;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Add up migration script here
CREATE TABLE privacy_links (
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,
title VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
course_id UUID NOT NULL REFERENCES courses(id)
);

CREATE TRIGGER set_timestamp BEFORE
UPDATE ON privacy_links FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();

COMMENT ON TABLE privacy_links IS 'This table stores custom privacy links for specific courses. By default, a generic privacy link is displayed in the website footer, but adding rows to this table allows overriding the default link with course-specific privacy URLs.';
COMMENT ON COLUMN privacy_links.id IS 'A unique identifier for the privacy link record.';
COMMENT ON COLUMN privacy_links.created_at IS 'Timestamp of when the record was created.';
COMMENT ON COLUMN privacy_links.updated_at IS 'Timestamp of the last update, automatically set by the set_timestamp trigger.';
COMMENT ON COLUMN privacy_links.deleted_at IS 'Timestamp of when the record was marked as deleted, if applicable.';
COMMENT ON COLUMN privacy_links.title IS 'The title or description of the privacy link.';
COMMENT ON COLUMN privacy_links.url IS 'The URL for the privacy link content.';
COMMENT ON COLUMN privacy_links.course_id IS 'The course ID the privacy link is associated with.';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit d937f33

Please sign in to comment.