diff --git a/.changeset/config.json b/.changeset/config.json index 52fe169dd..4714d95e1 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -20,6 +20,9 @@ "@courselit/common-logic", "@courselit/page-primitives", "@courselit/common-models", - "@courselit/docs" + "@courselit/docs", + "@courselit/orm-models", + "@courselit/page-models", + "@courselit/scripts" ] } diff --git a/.changeset/empty-rockets-allow.md b/.changeset/empty-rockets-allow.md new file mode 100644 index 000000000..a291c9d54 --- /dev/null +++ b/.changeset/empty-rockets-allow.md @@ -0,0 +1,5 @@ +--- +"@courselit/email-editor": patch +--- + +Bug fix: Lists don't render diff --git a/.github/workflows/publish-packages.yaml b/.github/workflows/publish-packages.yaml index 0059349a6..676dcf0e1 100644 --- a/.github/workflows/publish-packages.yaml +++ b/.github/workflows/publish-packages.yaml @@ -15,15 +15,19 @@ env: jobs: publish-packages: runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + pull-requests: write steps: - name: checkout - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Configure CI Git User run: | git config --global user.name 'Rajat Saxena' git config --global user.email 'hi@sub.rajatsaxena.dev' - git remote set-url origin https://$GITHUB_ACTOR:$GITHUB_PAT@github.com/codelitdev/courselit + git remote set-url origin https://x-access-token:${{ secrets.PAT }}@github.com/${{ github.repository }} env: GITHUB_PAT: ${{ secrets.PAT }} @@ -33,20 +37,13 @@ jobs: git pull origin ${{ github.ref_name }} - name: Setup pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: - version: 8 + version: latest - name: Install Packages run: pnpm install - - name: Authenticate with Registry - run: | - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc - pnpm whoami - env: - NPM_TOKEN: ${{ secrets.NPM }} - - name: Create and publish versions id: changesets uses: changesets/action@v1 @@ -56,7 +53,6 @@ jobs: publish: pnpm ci:publish env: GITHUB_TOKEN: ${{ secrets.PAT }} - NPM_TOKEN: ${{ secrets.NPM }} - name: Echo changeset output run: echo "${{ steps.changesets.outputs.hasChangesets }}" \ No newline at end of file diff --git a/apps/web/.migrations/28-03-26_00-00-set-quiz-requires-enrollment.js b/apps/web/.migrations/28-03-26_00-00-set-quiz-requires-enrollment.js new file mode 100644 index 000000000..fd294afab --- /dev/null +++ b/apps/web/.migrations/28-03-26_00-00-set-quiz-requires-enrollment.js @@ -0,0 +1,39 @@ +/** + * Sets `requiresEnrollment` to true for all quiz lessons. + * + * Usage: + * DB_CONNECTION_STRING= node 28-03-26_00-00-set-quiz-requires-enrollment.js + */ +import mongoose from "mongoose"; + +const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING; + +if (!DB_CONNECTION_STRING) { + throw new Error("DB_CONNECTION_STRING is not set"); +} + +(async () => { + try { + await mongoose.connect(DB_CONNECTION_STRING); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Could not connect to database"); + } + + const result = await db.collection("lessons").updateMany( + { type: "quiz" }, + { + $set: { + requiresEnrollment: true, + }, + }, + ); + + console.log( + `✅ Updated quiz lessons. Matched: ${result.matchedCount}, Modified: ${result.modifiedCount}`, + ); + } finally { + await mongoose.connection.close(); + } +})(); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index 41a92cbef..c581a354b 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx @@ -604,32 +604,34 @@ export default function LessonPage() { /> )} - {product?.type?.toLowerCase() !== - UIConstants.COURSE_TYPE_DOWNLOAD && ( -
-
- -

- Allow students to preview this - lesson without enrolling -

+ {product?.type?.toLowerCase() === + Constants.CourseType.COURSE && + lesson.type !== Constants.LessonType.QUIZ && ( +
+
+ +

+ Allow students to preview this + lesson without enrolling +

+
+ + updateLesson({ + requiresEnrollment: + !checked, + }) + } + />
- - updateLesson({ - requiresEnrollment: !checked, - }) - } - /> -
- )} + )}
diff --git a/apps/web/components/admin/page-editor/index.tsx b/apps/web/components/admin/page-editor/index.tsx index 66d683cb4..d4472e1c7 100644 --- a/apps/web/components/admin/page-editor/index.tsx +++ b/apps/web/components/admin/page-editor/index.tsx @@ -90,7 +90,7 @@ export default function PageEditor({ Page & { draftTitle?: string; draftDescription?: string; - draftSocialImage?: Media; + draftSocialImage?: Media | null; draftRobotsAllowed?: boolean; } > @@ -595,7 +595,9 @@ export default function PageEditor({ : true } socialImage={ - page.draftSocialImage ?? page.socialImage ?? null + page.draftSocialImage !== undefined + ? page.draftSocialImage + : (page.socialImage ?? null) } onClose={(e) => setLeftPaneContent("none")} onSave={({ diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index ee94136bc..c502a8559 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -365,7 +365,10 @@ const Settings = (props: SettingsProps) => { ) => { event.preventDefault(); - if (!newSettings.codeInjectionHead && !newSettings.codeInjectionBody) { + if ( + newSettings.codeInjectionHead === settings.codeInjectionHead && + newSettings.codeInjectionBody === settings.codeInjectionBody + ) { return; } diff --git a/apps/web/components/community/comment-section.tsx b/apps/web/components/community/comment-section.tsx index f6f8161a2..cdd3f106b 100644 --- a/apps/web/components/community/comment-section.tsx +++ b/apps/web/components/community/comment-section.tsx @@ -594,7 +594,7 @@ export default function CommentSection({ return (
-
+
{comments.map((comment) => ( ))} - +
diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index bea629cc5..930c98bf1 100644 --- a/apps/web/components/community/index.tsx +++ b/apps/web/components/community/index.tsx @@ -1413,7 +1413,7 @@ export function CommunityForum({ }} > diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts index 595e3bb42..12be83b4b 100644 --- a/apps/web/config/strings.ts +++ b/apps/web/config/strings.ts @@ -139,6 +139,7 @@ export const responses = { certificate_invalid_settings: "Certificate can only be enabled for courses", sso_provider_already_exists: "A SSO provider with the same provider ID already exists", + quiz_cannot_be_previewed: "Quiz cannot be previewed", // api responses digital_download_no_files: diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts index 6bcb82b9b..d7663139d 100644 --- a/apps/web/graphql/lessons/helpers.ts +++ b/apps/web/graphql/lessons/helpers.ts @@ -9,7 +9,7 @@ const { text, audio, video, pdf, embed, quiz, file } = constants; type LessonValidatorProps = Pick< LessonWithStringContent, - "content" | "type" | "media" + "content" | "type" | "media" | "requiresEnrollment" >; export const lessonValidator = (lessonData: LessonValidatorProps) => { @@ -38,6 +38,16 @@ export function validateTextContent(lessonData: LessonValidatorProps) { } if (lessonData.type === quiz) { + if ( + Object.prototype.hasOwnProperty.call( + lessonData, + "requiresEnrollment", + ) && + !lessonData.requiresEnrollment + ) { + throw new Error(responses.quiz_cannot_be_previewed); + } + if (content && content.questions) { validateQuizContent(content.questions); } diff --git a/apps/web/graphql/pages/__tests__/logic.test.ts b/apps/web/graphql/pages/__tests__/logic.test.ts index 7482baf98..b27f9abae 100644 --- a/apps/web/graphql/pages/__tests__/logic.test.ts +++ b/apps/web/graphql/pages/__tests__/logic.test.ts @@ -8,7 +8,7 @@ import PageModel, { Page } from "@/models/Page"; import Course from "@/models/Course"; import CommunityModel from "@/models/Community"; import constants from "@/config/constants"; -import { deleteMedia } from "@/services/medialit"; +import { deleteMedia, sealMedia } from "@/services/medialit"; import GQLContext from "@/models/GQLContext"; import { responses } from "@/config/strings"; @@ -844,6 +844,92 @@ describe("Media cleanup", () => { expect(deleteMedia).not.toHaveBeenCalledWith(media1); }); + it("sets draftSocialImage to null when socialImage is null", async () => { + const page = await PageModel.create({ + domain: ctx.subdomain._id, + pageId: "update-clears-draft-social-image", + type: constants.site, + creatorId: "creator-1", + name: "Update Clears Draft Social Image", + layout: [], + draftLayout: [], + draftSocialImage: mediaObj1, + }); + + await updatePage({ + context: ctx, + pageId: page.pageId, + socialImage: null, + }); + + const refreshedPage = await PageModel.findOne({ + _id: page._id, + }); + + expect(refreshedPage?.draftSocialImage).toBeNull(); + expect(deleteMedia).toHaveBeenCalledWith(media1); + }); + + it("does NOT delete the cleared draftSocialImage if published socialImage still uses it", async () => { + const page = await PageModel.create({ + domain: ctx.subdomain._id, + pageId: "update-clears-draft-but-protects-published-social", + type: constants.site, + creatorId: "creator-1", + name: "Update Clears Draft But Protects Published Social", + layout: [], + draftLayout: [], + socialImage: mediaObj1, + draftSocialImage: mediaObj1, + }); + + await updatePage({ + context: ctx, + pageId: page.pageId, + socialImage: null, + }); + + const refreshedPage = await PageModel.findOne({ + _id: page._id, + }); + + expect(refreshedPage?.draftSocialImage).toBeNull(); + expect(deleteMedia).not.toHaveBeenCalledWith(media1); + }); + + it("does not delete the existing draftSocialImage when sealing the new social image fails", async () => { + const page = await PageModel.create({ + domain: ctx.subdomain._id, + pageId: "update-social-image-seal-failure", + type: constants.site, + creatorId: "creator-1", + name: "Update Social Image Seal Failure", + layout: [], + draftLayout: [], + draftSocialImage: mediaObj1, + }); + + (sealMedia as jest.Mock).mockRejectedValueOnce( + new Error("seal failed"), + ); + + await expect( + updatePage({ + context: ctx, + pageId: page.pageId, + socialImage: mediaObj2 as any, + }), + ).rejects.toThrow("seal failed"); + + expect(deleteMedia).not.toHaveBeenCalledWith(media1); + + const refreshedPage = await PageModel.findOne({ + _id: page._id, + }); + + expect(refreshedPage?.draftSocialImage?.mediaId).toBe(media1); + }); + it("handles multiple media in a single widget", async () => { const page = await PageModel.create({ domain: ctx.subdomain._id, @@ -1091,6 +1177,29 @@ describe("Media cleanup", () => { expect(deleteMedia).not.toHaveBeenCalledWith(media1); }); + it("clears published socialImage when draftSocialImage is null", async () => { + const page = await PageModel.create({ + domain: ctx.subdomain._id, + pageId: "publish-clears-social-image", + type: constants.site, + creatorId: "creator-1", + name: "Publish Clears Social Image", + layout: [], + draftLayout: [], + socialImage: mediaObj1, + draftSocialImage: null, + }); + + await publish(page.pageId, ctx); + + const refreshedPage = await PageModel.findOne({ + _id: page._id, + }); + + expect(refreshedPage?.socialImage).toBeUndefined(); + expect(deleteMedia).toHaveBeenCalledWith(media1); + }); + it("handles publishing with empty draftLayout - media is deleted", async () => { // Note: When draftLayout is empty, the layout is NOT copied (checked via `if (page.draftLayout.length)`) // However, the media diff computation still happens: currentPublished - nextPublished diff --git a/apps/web/graphql/pages/logic.ts b/apps/web/graphql/pages/logic.ts index 96c4e5709..9f306f5f0 100644 --- a/apps/web/graphql/pages/logic.ts +++ b/apps/web/graphql/pages/logic.ts @@ -134,7 +134,7 @@ export const updatePage = async ({ layout?: string; title?: string; description?: string; - socialImage?: Media; + socialImage?: Media | null; robotsAllowed?: boolean; }): Promise | null> => { checkIfAuthenticated(ctx); @@ -194,13 +194,19 @@ export const updatePage = async ({ if (description) { page.draftDescription = description; } - if (typeof socialImage !== "undefined" && socialImage?.mediaId) { - if (page.draftSocialImage?.mediaId) { - deletedMediaIds.push(page.draftSocialImage.mediaId); + if (typeof socialImage !== "undefined") { + const previousDraftSocialImageId = page.draftSocialImage?.mediaId; + + if (socialImage === null) { + page.draftSocialImage = null; + } else if (socialImage.mediaId) { + const sealedMedia = await sealMedia(socialImage.mediaId); + page.draftSocialImage = sealedMedia; + } + + if (previousDraftSocialImageId) { + deletedMediaIds.push(previousDraftSocialImageId); } - page.draftSocialImage = socialImage?.mediaId - ? await sealMedia(socialImage.mediaId) - : undefined; } if (typeof robotsAllowed === "boolean") { page.draftRobotsAllowed = robotsAllowed; @@ -211,7 +217,12 @@ export const updatePage = async ({ ); for (const mediaId of deletableMediaIds) { - await deleteMedia(mediaId); + try { + await deleteMedia(mediaId); + } catch (err) { + // eslint-disable-next-line no-console + console.log(`Error while deleting media`, mediaId); + } } try { @@ -281,7 +292,11 @@ export const publish = async ( page.robotsAllowed = page.draftRobotsAllowed; // page.draftRobotsAllowed = undefined; } - page.socialImage = page.draftSocialImage; + if (page.draftSocialImage === null) { + page.socialImage = undefined; + } else if (typeof page.draftSocialImage !== "undefined") { + page.socialImage = page.draftSocialImage; + } if (ctx.subdomain.themeId) { await publishTheme(ctx.subdomain.themeId, ctx); diff --git a/apps/web/graphql/pages/mutation.ts b/apps/web/graphql/pages/mutation.ts index 2d698ba45..62aa7d940 100644 --- a/apps/web/graphql/pages/mutation.ts +++ b/apps/web/graphql/pages/mutation.ts @@ -40,7 +40,7 @@ const mutations = { layout?: string; title?: string; description?: string; - socialImage?: Media; + socialImage?: Media | null; robotsAllowed?: boolean; }, context: GQLContext, diff --git a/apps/web/graphql/themes/logic.ts b/apps/web/graphql/themes/logic.ts index b75f0128a..0b9707a62 100644 --- a/apps/web/graphql/themes/logic.ts +++ b/apps/web/graphql/themes/logic.ts @@ -8,6 +8,7 @@ import UserThemeModel from "@/models/UserTheme"; import { themes as SystemThemes } from "@courselit/page-primitives"; import { ThemeStyle } from "@courselit/page-models"; import { UITheme } from "@models/UITheme"; +import { invalidateDomainCache } from "@/lib/domain-cache"; const { permissions } = constants; @@ -224,6 +225,8 @@ export const switchTheme = async (themeId: string, ctx: GQLContext) => { { $set: { themeId, lastEditedThemeId: themeId } }, ); + invalidateDomainCache(ctx.subdomain.name); + return formatTheme(theme); }; diff --git a/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts b/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts index f745d0362..810f6fbf4 100644 --- a/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts +++ b/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts @@ -7,7 +7,7 @@ export async function replaceTempMediaWithSealedMediaInPageLayout( ): Promise { const mediaIds = Array.from(extractMediaIDs(JSON.stringify(layout))); for (const mediaId of mediaIds) { - const media = await sealMedia(mediaId); + const media = await safeSealMedia(mediaId); if (media) { layout = replaceMediaURLinPageLayout(layout, mediaId, media); } @@ -16,6 +16,14 @@ export async function replaceTempMediaWithSealedMediaInPageLayout( return layout; } +export async function safeSealMedia(mediaId: string) { + try { + return await sealMedia(mediaId); + } catch (err) { + console.error(`Error while sealing media`, mediaId); + } +} + function replaceMediaURLinPageLayout( layout: any, mediaId: string, diff --git a/apps/web/models/Page.ts b/apps/web/models/Page.ts index b339add50..2ba5d5abf 100644 --- a/apps/web/models/Page.ts +++ b/apps/web/models/Page.ts @@ -16,7 +16,7 @@ export interface Page extends PublicPage { creatorId: string; draftTitle?: string; draftDescription?: string; - draftSocialImage?: Media; + draftSocialImage?: Media | null; draftRobotsAllowed?: boolean; } diff --git a/packages/common-models/src/page.ts b/packages/common-models/src/page.ts index 792ef4bfe..f7a6a232e 100644 --- a/packages/common-models/src/page.ts +++ b/packages/common-models/src/page.ts @@ -18,6 +18,6 @@ export default interface Page { robotsAllowed?: boolean; draftTitle?: string; draftDescription?: string; - draftSocialImage?: Media; + draftSocialImage?: Media | null; draftRobotsAllowed?: boolean; } diff --git a/packages/email-editor/src/blocks/text/block.tsx b/packages/email-editor/src/blocks/text/block.tsx index 2d0a92d7c..8fb77066e 100644 --- a/packages/email-editor/src/blocks/text/block.tsx +++ b/packages/email-editor/src/blocks/text/block.tsx @@ -23,6 +23,39 @@ export function TextBlock({ block, style, selectedBlockId }: TextBlockProps) { const content = block.settings.content || (isSelected ? "" : "Text content"); + // Common text styles to avoid repetition + const commonTextStyles = { + fontFamily: + block.settings.fontFamily || + style?.typography.text.fontFamily || + "Arial, sans-serif", + fontSize: + block.settings.fontSize || + style?.typography.text.fontSize || + "16px", + lineHeight: + block.settings.lineHeight || + style?.typography.text.lineHeight || + "1.5", + letterSpacing: + block.settings.letterSpacing || + style?.typography.text.letterSpacing || + "normal", + textTransform: + block.settings.textTransform || + style?.typography.text.textTransform || + "none", + textDecoration: + block.settings.textDecoration || + style?.typography.text.textDecoration || + "none", + }; + + const headerFontFamily = + block.settings.fontFamily || + style?.typography.header.fontFamily || + "Arial, sans-serif"; + return (
(settings.verticalPadding); const [cssId, setCssId] = useState(settings.cssId); + const [openByDefault, setOpenByDefault] = useState( + settings.openByDefault || false, + ); useEffect(() => { onChange({ @@ -47,9 +53,18 @@ export default function AdminWidget({ headerAlignment, maxWidth, verticalPadding, + openByDefault, cssId, }); - }, [title, description, headerAlignment, maxWidth, verticalPadding, cssId]); + }, [ + title, + description, + headerAlignment, + maxWidth, + verticalPadding, + openByDefault, + cssId, + ]); return ( +
+
+

Open by default

+ + + +
+ setOpenByDefault(value)} + /> +
diff --git a/packages/page-blocks/src/blocks/content/settings.ts b/packages/page-blocks/src/blocks/content/settings.ts index 46306125b..74475a31b 100644 --- a/packages/page-blocks/src/blocks/content/settings.ts +++ b/packages/page-blocks/src/blocks/content/settings.ts @@ -8,5 +8,6 @@ export default interface Settings extends WidgetDefaultSettings { title: string; description: TextEditorContent; headerAlignment: Alignment; + openByDefault?: boolean; cssId?: string; } diff --git a/packages/page-blocks/src/blocks/content/widget.tsx b/packages/page-blocks/src/blocks/content/widget.tsx index 354d07f9e..be26e347b 100644 --- a/packages/page-blocks/src/blocks/content/widget.tsx +++ b/packages/page-blocks/src/blocks/content/widget.tsx @@ -42,6 +42,7 @@ export default function Widget({ cssId, maxWidth, verticalPadding, + openByDefault = false, }, state, pageData: product, @@ -184,59 +185,65 @@ export default function Widget({ ))}
)} - - {Object.keys(formattedCourse).map((group, index) => ( - 0 && + (openByDefault ? ( + - -
+ {renderItems()} + + ) : ( + + {renderItems()} + + ))} +
+
+ ); + + function renderItems() { + return Object.keys(formattedCourse).map((group) => ( + + +
+ + {group} + + + {`${formattedCourse[group].length} lessons`} + +
+
+ + {formattedCourse[group].map((lesson: Lesson) => ( + +
+ + + - {group} + {lesson.title} - - {`${formattedCourse[group].length} lessons`} - -
- - - {formattedCourse[group].map( - (lesson: Lesson) => ( - -
- - + - - {lesson.title} - - - - {!lesson.requiresEnrollment && ( - - Preview - - )} -
- - ), + {!lesson.requiresEnrollment && ( + Preview )} -
-
+
+ ))} - -
- - ); + + + )); + } } diff --git a/packages/page-blocks/src/blocks/faq/widget.tsx b/packages/page-blocks/src/blocks/faq/widget.tsx index 7489fd19f..72aad062f 100644 --- a/packages/page-blocks/src/blocks/faq/widget.tsx +++ b/packages/page-blocks/src/blocks/faq/widget.tsx @@ -99,7 +99,10 @@ export default function Widget({ value={`${item.title}-${index}`} > - + {item.title} diff --git a/packages/page-models/package.json b/packages/page-models/package.json index d461a5f4b..1c5e73140 100644 --- a/packages/page-models/package.json +++ b/packages/page-models/package.json @@ -2,6 +2,7 @@ "name": "@courselit/page-models", "version": "0.0.0", "description": "CourseLit Page Builder's models", + "private": true, "author": "Rajat Saxena ", "homepage": "https://github.com/codelitdev/courselit#readme", "license": "MIT",