Skip to content

Commit

Permalink
Allow customizing course checkbox privacy policy texts, make wide tab…
Browse files Browse the repository at this point in the history
…les responsive inside exercises, and misc fixes (#1357)

* Support tables with a lot of columns inside exercise blocks

* Allow customizing custom privacy policy checkbox texts on the course settings dialog

* Add error handling to marketing consent form and update privacy policy checkbox table schema

* Update Ukrainian translations
  • Loading branch information
nygrenh authored Dec 13, 2024
1 parent 1ec21a6 commit 1b62ba6
Show file tree
Hide file tree
Showing 16 changed files with 340 additions and 108 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export default function editBlockThemeJsonSettings(
settingValue: unknown,
_settingName: string,
settingName: string,
_clientId: string,
_blockName: string,
blockName: string,
): unknown {
// Here we can turn on theme.json settings for the block editor

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { css } from "@emotion/css"
import { useContext } from "react"
import { useContext, useMemo } from "react"

import { BlockRendererProps } from "../.."
import {
Expand All @@ -20,7 +20,7 @@ interface ExtraAttributes {

const TableBlock: React.FC<
React.PropsWithChildren<BlockRendererProps<TableAttributes & ExtraAttributes>>
> = ({ data }) => {
> = ({ data, dontAllowBlockToBeWiderThanContainerWidth }) => {
const {
hasFixedLayout,
caption,
Expand All @@ -32,6 +32,12 @@ const TableBlock: React.FC<
const head = data.attributes.head
const foot = data.attributes.foot

const hasManyColumns = useMemo(
() => body.length > 0 && body[0].cells && body[0].cells.length > 5,
[body],
)
const shouldUseSmallerFont = hasManyColumns && dontAllowBlockToBeWiderThanContainerWidth

const { terms } = useContext(GlossaryContext)
const isStriped = className === "is-style-stripes"

Expand All @@ -46,7 +52,11 @@ const TableBlock: React.FC<
}

return (
<div>
<div
className={css`
overflow-x: scroll;
`}
>
<table
className={css`
border-collapse: collapse;
Expand All @@ -68,6 +78,8 @@ const TableBlock: React.FC<
tfoot {
border-top: 3px solid;
}
${shouldUseSmallerFont && `font-size: 15px;`}
`}
>
{head && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { t } from "i18next"
import React, { useEffect, useState } from "react"
import React, { useEffect, useMemo, useState } from "react"

import { fetchUserMarketingConsent } from "@/services/backend"
import {
fetchCustomPrivacyPolicyCheckboxTexts,
fetchUserMarketingConsent,
} from "@/services/backend"
import ErrorBanner from "@/shared-module/common/components/ErrorBanner"
import CheckBox from "@/shared-module/common/components/InputFields/CheckBox"
import Spinner from "@/shared-module/common/components/Spinner"
import { assertNotNullOrUndefined } from "@/shared-module/common/utils/nullability"

interface SelectMarketingConsentFormProps {
Expand All @@ -20,12 +25,17 @@ const SelectMarketingConsentForm: React.FC<SelectMarketingConsentFormProps> = ({
const [marketingConsent, setMarketingConsent] = useState(false)
const [emailSubscriptionConsent, setEmailSubscriptionConsent] = useState(false)

const fetchInitialMarketingConsent = useQuery({
const initialMarketingConsentQuery = useQuery({
queryKey: ["marketing-consent", courseId],
queryFn: () => fetchUserMarketingConsent(assertNotNullOrUndefined(courseId)),
enabled: courseId !== undefined,
})

const customPrivacyPolicyCheckboxTextsQuery = useQuery({
queryKey: ["customPrivacyPolicyCheckboxTexts", courseId],
queryFn: () => fetchCustomPrivacyPolicyCheckboxTexts(courseId),
})

const handleEmailSubscriptionConsentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const isChecked = event.target.checked
onEmailSubscriptionConsentChange(isChecked)
Expand All @@ -39,24 +49,62 @@ const SelectMarketingConsentForm: React.FC<SelectMarketingConsentFormProps> = ({
}

useEffect(() => {
if (fetchInitialMarketingConsent.isSuccess) {
setMarketingConsent(fetchInitialMarketingConsent.data.consent)
if (initialMarketingConsentQuery.isSuccess) {
setMarketingConsent(initialMarketingConsentQuery.data.consent)
const emailSub =
fetchInitialMarketingConsent.data.email_subscription_in_mailchimp === "subscribed"
initialMarketingConsentQuery.data.email_subscription_in_mailchimp === "subscribed"
setEmailSubscriptionConsent(emailSub)
}
}, [fetchInitialMarketingConsent.data, fetchInitialMarketingConsent.isSuccess])
}, [initialMarketingConsentQuery.data, initialMarketingConsentQuery.isSuccess])

const marketingConsentCheckboxText = useMemo(() => {
if (customPrivacyPolicyCheckboxTextsQuery.isSuccess) {
const customText = customPrivacyPolicyCheckboxTextsQuery.data.find(
(text) => text.text_slug === "marketing-consent",
)
if (customText) {
return customText.text_html
}
}
return t("marketing-consent-checkbox-text")
}, [customPrivacyPolicyCheckboxTextsQuery.data, customPrivacyPolicyCheckboxTextsQuery.isSuccess])

const marketingConsentPrivacyPolicyCheckboxText = useMemo(() => {
if (customPrivacyPolicyCheckboxTextsQuery.isSuccess) {
const customText = customPrivacyPolicyCheckboxTextsQuery.data.find(
(text) => text.text_slug === "privacy-policy",
)
if (customText) {
return customText.text_html
}
}
return t("marketing-consent-privacy-policy-checkbox-text")
}, [customPrivacyPolicyCheckboxTextsQuery.data, customPrivacyPolicyCheckboxTextsQuery.isSuccess])

if (initialMarketingConsentQuery.isLoading || customPrivacyPolicyCheckboxTextsQuery.isLoading) {
return <Spinner variant="small" />
}
if (initialMarketingConsentQuery.isError || customPrivacyPolicyCheckboxTextsQuery.isError) {
return (
<ErrorBanner
variant="readOnly"
error={initialMarketingConsentQuery.error ?? customPrivacyPolicyCheckboxTextsQuery.error}
/>
)
}

return (
<>
<CheckBox
label={t("marketing-consent-checkbox-text")}
label={marketingConsentCheckboxText}
labelIsRawHtml
type="checkbox"
checked={marketingConsent}
onChange={handleMarketingConsentChange}
></CheckBox>
<CheckBox
label={t("marketing-consent-privacy-policy-checkbox-text")}
label={marketingConsentPrivacyPolicyCheckboxText}
labelIsRawHtml
type="checkbox"
checked={emailSubscriptionConsent}
onChange={handleEmailSubscriptionConsentChange}
Expand Down
12 changes: 12 additions & 0 deletions services/course-material/src/services/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CodeGiveawayStatus,
Course,
CourseBackgroundQuestionsAndAnswers,
CourseCustomPrivacyPolicyCheckboxText,
CourseInstance,
CourseMaterialExercise,
CourseMaterialPeerOrSelfReviewDataWithToken,
Expand Down Expand Up @@ -61,6 +62,7 @@ import {
isCodeGiveawayStatus,
isCourse,
isCourseBackgroundQuestionsAndAnswers,
isCourseCustomPrivacyPolicyCheckboxText,
isCourseInstance,
isCourseMaterialExercise,
isCourseMaterialPeerOrSelfReviewDataWithToken,
Expand Down Expand Up @@ -786,3 +788,13 @@ export const fetchPrivacyLink = async (courseId: string): Promise<PrivacyLink[]>
const response = await courseMaterialClient.get(`/courses/${courseId}/privacy-link`)
return validateResponse(response, isArray(isPrivacyLink))
}

export const fetchCustomPrivacyPolicyCheckboxTexts = async (
courseId: string,
): Promise<CourseCustomPrivacyPolicyCheckboxText[]> => {
const response = await courseMaterialClient.get(
`/courses/${courseId}/custom-privacy-policy-checkbox-texts`,
{ responseType: "json" },
)
return validateResponse(response, isArray(isCourseCustomPrivacyPolicyCheckboxText))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE course_custom_privacy_policy_checkbox_texts;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
CREATE TABLE course_custom_privacy_policy_checkbox_texts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
course_id UUID NOT NULL REFERENCES courses(id),
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,
text_html TEXT NOT NULL,
text_slug TEXT NOT NULL,
UNIQUE NULLS NOT DISTINCT (course_id, text_slug, deleted_at)
);
CREATE TRIGGER set_timestamp BEFORE
UPDATE ON course_custom_privacy_policy_checkbox_texts FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
COMMENT ON TABLE course_custom_privacy_policy_checkbox_texts IS 'Used to set the privacy policy checkbox texts in the course settings dialog when a course has a different privacy policy than all the other courses. (e.g., the Elements of AI course)';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.id IS 'A unique, stable identifier for the record.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.created_at IS 'Timestamp when the record was created.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.text_html IS 'The HTML content of the text.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.text_slug IS 'An identifier for the text, used to reference it in the course settings dialog.';
COMMENT ON COLUMN course_custom_privacy_policy_checkbox_texts.course_id IS 'The course in which the text is shown.';

CREATE INDEX course_custom_privacy_policy_checkbox_texts_course_id_idx ON course_custom_privacy_policy_checkbox_texts (course_id)
WHERE deleted_at IS NULL;

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use crate::prelude::*;

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(feature = "ts_rs", derive(TS))]
pub struct CourseCustomPrivacyPolicyCheckboxText {
pub id: Uuid,
pub course_id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
pub text_html: String,
pub text_slug: String,
}

pub async fn get_all_by_course_id(
conn: &mut PgConnection,
course_id: Uuid,
) -> ModelResult<Vec<CourseCustomPrivacyPolicyCheckboxText>> {
let texts = sqlx::query_as!(
CourseCustomPrivacyPolicyCheckboxText,
r#"
SELECT *
FROM course_custom_privacy_policy_checkbox_texts
WHERE course_id = $1
AND deleted_at IS NULL;
"#,
course_id,
)
.fetch_all(conn)
.await?;
Ok(texts)
}
1 change: 1 addition & 0 deletions services/headless-lms/models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod code_giveaway_codes;
pub mod code_giveaways;
pub mod course_background_question_answers;
pub mod course_background_questions;
pub mod course_custom_privacy_policy_checkbox_texts;
pub mod course_exams;
pub mod course_instance_enrollments;
pub mod course_instances;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ 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::marketing_consents::UserMarketingConsent;
use headless_lms_models::{
course_custom_privacy_policy_checkbox_texts::CourseCustomPrivacyPolicyCheckboxText,
marketing_consents::UserMarketingConsent,
};
use headless_lms_models::{partner_block::PartnersBlock, privacy_link::PrivacyLink};
use headless_lms_utils::ip_to_country::IpToCountryMapper;
use isbot::Bots;
Expand Down Expand Up @@ -1002,6 +1005,27 @@ async fn get_privacy_link(
token.authorized_ok(web::Json(privacy_link))
}

/**
GET /courses/:course_id/custom-privacy-policy-checkbox-texts - Used to get customized checkbox texts for courses that use a different privacy policy than all our other courses (e.g. the Elements of AI course). These texts are shown in the course settings dialog.
*/
#[instrument(skip(pool))]
async fn get_custom_privacy_policy_checkbox_texts(
course_id: web::Path<Uuid>,
pool: web::Data<PgPool>,
user: AuthUser, // Ensure the user is authenticated
) -> ControllerResult<web::Json<Vec<CourseCustomPrivacyPolicyCheckboxText>>> {
let mut conn = pool.acquire().await?;

let token = authorize_access_to_course_material(&mut conn, Some(user.id), *course_id).await?;

let texts = models::course_custom_privacy_policy_checkbox_texts::get_all_by_course_id(
&mut conn, *course_id,
)
.await?;

token.authorized_ok(web::Json(texts))
}

/**
Add a route for each controller in this module.
Expand Down Expand Up @@ -1096,5 +1120,9 @@ pub fn _add_routes(cfg: &mut ServiceConfig) {
.route(
"/{course_id}/fetch-user-marketing-consent",
web::get().to(fetch_user_marketing_consent),
)
.route(
"/{course_id}/custom-privacy-policy-checkbox-texts",
web::get().to(get_custom_privacy_policy_checkbox_texts),
);
}
Loading

0 comments on commit 1b62ba6

Please sign in to comment.