Skip to content

Commit ce9ca81

Browse files
authored
Render article editor content on the server #9760 (#2847)
* Download digital credential dialog * Missing icon * Force long words to wrap * Body as scroll container * Prefetch from article API * Render article detail with static render and provide banner and byline viewers * Image viewer and placeholder * Lockfile * Extract learning resource IDs and prefetch * Viewers for learning cards and divider * Export viewer * Fix/filter iframe props * Style lint ignores * Consolidate resource ID extraction and prefetch behavior * Remove float, not in use * Remove comments
1 parent d757bba commit ce9ca81

19 files changed

Lines changed: 518 additions & 274 deletions

File tree

frontends/main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@tiptap/pm": "^3.13.0",
3939
"@tiptap/react": "^3.13.0",
4040
"@tiptap/starter-kit": "^3.13.0",
41+
"@tiptap/static-renderer": "^3.13.0",
4142
"api": "workspace:*",
4243
"async_hooks": "^1.0.0",
4344
"classnames": "^2.5.1",

frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor"
77
import { notFound } from "next/navigation"
88
import { useFeatureFlagEnabled } from "posthog-js/react"
99
import { FeatureFlags } from "@/common/feature_flags"
10+
import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
11+
import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider"
1012

1113
const PageContainer = styled.div({
1214
display: "flex",
@@ -21,22 +23,40 @@ const Spinner = styled(LoadingSpinner)({
2123
transform: "translate(-50%, -50%)",
2224
})
2325

24-
export const ArticleDetailPage = ({ articleId }: { articleId: string }) => {
26+
export const ArticleDetailPage = ({
27+
articleId,
28+
learningResourceIds = [],
29+
}: {
30+
articleId: string
31+
learningResourceIds?: number[]
32+
}) => {
2533
const { data: article, isLoading } = useArticleDetailRetrieve(articleId)
2634

2735
const showArticleDetail = useFeatureFlagEnabled(
2836
FeatureFlags.ArticleEditorView,
2937
)
38+
const flagsLoaded = useFeatureFlagsLoaded()
3039

31-
if (isLoading) {
32-
return <Spinner color="inherit" loading size={32} />
40+
/* Ensure queries are accessed during loading/flag check.
41+
* This prevents React Query warnings about prefetched queries not being accessed.
42+
* We can remove the early LearningResourceProvider when we remove the feature flag.
43+
*/
44+
if (isLoading || (!flagsLoaded && showArticleDetail === undefined)) {
45+
return (
46+
<LearningResourceProvider resourceIds={learningResourceIds}>
47+
<Spinner color="inherit" loading size={32} />
48+
</LearningResourceProvider>
49+
)
3350
}
34-
if (!article || !showArticleDetail) {
51+
if (!showArticleDetail || !article) {
3552
return notFound()
3653
}
54+
3755
return (
3856
<PageContainer>
39-
<ArticleEditor article={article} readOnly />
57+
<LearningResourceProvider resourceIds={learningResourceIds}>
58+
<ArticleEditor article={article} readOnly />
59+
</LearningResourceProvider>
4060
</PageContainer>
4161
)
4262
}

frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,13 @@ const CertificatePage: React.FC<{
800800
Download Digital Credential
801801
</Button>
802802
) : null}
803+
<Button
804+
variant="bordered"
805+
startIcon={<RiDownloadLine />}
806+
onClick={() => setDigitalCredentialDialogOpen(true)}
807+
>
808+
Download Digital Credential
809+
</Button>
803810
<Button
804811
variant="bordered"
805812
startIcon={<RiShareLine />}
Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import React from "react"
22
import { Metadata } from "next"
3+
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
4+
import { articleQueries } from "api/hooks/articles"
35
import { standardizeMetadata } from "@/common/metadata"
46
import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage"
7+
import { getQueryClient } from "@/app/getQueryClient"
8+
import { learningResourceQueries } from "api/hooks/learningResources"
9+
import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils"
510

611
export const metadata: Metadata = standardizeMetadata({
712
title: "Article Detail",
@@ -10,6 +15,33 @@ export const metadata: Metadata = standardizeMetadata({
1015
const Page: React.FC<PageProps<"/articles/[slugOrId]">> = async (props) => {
1116
const { slugOrId } = await props.params
1217

13-
return <ArticleDetailPage articleId={slugOrId} />
18+
const queryClient = getQueryClient()
19+
20+
await queryClient.prefetchQuery(
21+
articleQueries.articlesDetailRetrieve(slugOrId),
22+
)
23+
24+
const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey
25+
const cacheData = queryClient.getQueryData(queryKey)
26+
27+
const learningResourceIds = cacheData?.content
28+
? extractLearningResourceIds(cacheData.content)
29+
: []
30+
31+
if (learningResourceIds.length > 0) {
32+
const bulkQuery = learningResourceQueries.list({
33+
resource_id: learningResourceIds,
34+
})
35+
await queryClient.prefetchQuery(bulkQuery)
36+
}
37+
38+
return (
39+
<HydrationBoundary state={dehydrate(queryClient)}>
40+
<ArticleDetailPage
41+
articleId={slugOrId}
42+
learningResourceIds={learningResourceIds}
43+
/>
44+
</HydrationBoundary>
45+
)
1446
}
1547
export default Page

frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx

Lines changed: 96 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { Superscript } from "@tiptap/extension-superscript"
2626
import { Toolbar } from "./vendor/components/tiptap-ui-primitive/toolbar"
2727
import { Spacer } from "./vendor/components/tiptap-ui-primitive/spacer"
2828

29-
import TiptapEditor, { MainToolbarContent } from "./TiptapEditor"
29+
import { TiptapEditor, MainToolbarContent, TipTapViewer } from "./TiptapEditor"
3030
import { ArticleProvider } from "./ArticleContext"
3131

3232
import { DividerNode } from "./extensions/node/Divider/DividerNode"
@@ -40,14 +40,6 @@ import { HorizontalRule } from "./vendor/components/tiptap-node/horizontal-rule-
4040
import { ImageNode } from "./extensions/node/Image/ImageNode"
4141
import { ImageWithCaptionNode } from "./extensions/node/Image/ImageWithCaptionNode"
4242

43-
import "./vendor/components/tiptap-node/blockquote-node/blockquote-node.scss"
44-
import "./vendor/components/tiptap-node/code-block-node/code-block-node.scss"
45-
import "./vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
46-
import "./vendor/components/tiptap-node/list-node/list-node.scss"
47-
import "./vendor/components/tiptap-node/image-node/image-node.scss"
48-
import "./vendor/components/tiptap-node/heading-node/heading-node.scss"
49-
import "./vendor/components/tiptap-node/paragraph-node/paragraph-node.scss"
50-
5143
import type { ExtendedNodeConfig } from "./extensions/node/types"
5244
import { handleImageUpload, MAX_FILE_SIZE } from "./vendor/lib/tiptap-utils"
5345

@@ -225,6 +217,95 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
225217
)
226218
}
227219

220+
const extensions = [
221+
ArticleDocument,
222+
StarterKit.configure({
223+
document: false, // Disable default document to use our ArticleDocument
224+
horizontalRule: false,
225+
heading: false,
226+
link: {
227+
openOnClick: false,
228+
enableClickSelection: true,
229+
},
230+
trailingNode: {
231+
node: "paragraph",
232+
},
233+
}),
234+
Heading.configure({
235+
levels: [1, 2, 3, 4, 5, 6],
236+
}),
237+
Placeholder.configure({
238+
showOnlyCurrent: false,
239+
includeChildren: true,
240+
placeholder: ({ node, editor }): string => {
241+
let parentNode: typeof node | null = null
242+
243+
editor.state.doc.descendants((n: ProseMirrorNode) => {
244+
n.forEach((childNode: ProseMirrorNode) => {
245+
if (childNode === node) {
246+
parentNode = n
247+
}
248+
})
249+
if (parentNode) {
250+
return false
251+
}
252+
return undefined
253+
})
254+
255+
if (parentNode) {
256+
const parentExtension = editor.extensionManager.extensions.find(
257+
(ext) => ext.name === parentNode!.type.name,
258+
)
259+
260+
if (
261+
parentExtension &&
262+
"config" in parentExtension &&
263+
parentExtension.config &&
264+
typeof (parentExtension.config as ExtendedNodeConfig)
265+
.getPlaceholders === "function"
266+
) {
267+
const placeholder = (
268+
parentExtension.config as ExtendedNodeConfig
269+
).getPlaceholders(node)
270+
if (placeholder) {
271+
return placeholder
272+
}
273+
}
274+
}
275+
276+
if (node.type.name === "heading") {
277+
return "Add a heading"
278+
}
279+
return "Add some text"
280+
},
281+
}),
282+
HorizontalRule,
283+
LearningResourceURLHandler,
284+
LearningResourceNode,
285+
TextAlign.configure({ types: ["heading", "paragraph"] }),
286+
TaskList,
287+
TaskItem.configure({ nested: true }),
288+
Highlight.configure({ multicolor: true }),
289+
TiptapTypography,
290+
Superscript,
291+
Subscript,
292+
Selection,
293+
Image,
294+
MediaEmbedNode,
295+
DividerNode,
296+
ArticleByLineInfoBarNode,
297+
ImageWithCaptionNode,
298+
MediaEmbedURLHandler,
299+
ImageNode.configure({
300+
accept: "image/*",
301+
maxSize: MAX_FILE_SIZE,
302+
limit: 3,
303+
upload: uploadHandler,
304+
onError: (error) => setUploadError(error.message),
305+
}),
306+
BannerNode,
307+
]
308+
228309
const editor = useEditor({
229310
immediatelyRender: false,
230311
shouldRerenderOnTransaction: false,
@@ -256,94 +337,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
256337
class: "simple-editor",
257338
},
258339
},
259-
extensions: [
260-
ArticleDocument,
261-
StarterKit.configure({
262-
document: false, // Disable default document to use our ArticleDocument
263-
horizontalRule: false,
264-
heading: false,
265-
link: {
266-
openOnClick: false,
267-
enableClickSelection: true,
268-
},
269-
trailingNode: {
270-
node: "paragraph",
271-
},
272-
}),
273-
Heading.configure({
274-
levels: [1, 2, 3, 4, 5, 6],
275-
}),
276-
Placeholder.configure({
277-
showOnlyCurrent: false,
278-
includeChildren: true,
279-
placeholder: ({ node, editor }): string => {
280-
let parentNode: typeof node | null = null
281-
282-
editor.state.doc.descendants((n: ProseMirrorNode) => {
283-
n.forEach((childNode: ProseMirrorNode) => {
284-
if (childNode === node) {
285-
parentNode = n
286-
}
287-
})
288-
if (parentNode) {
289-
return false
290-
}
291-
return undefined
292-
})
293-
294-
if (parentNode) {
295-
const parentExtension = editor.extensionManager.extensions.find(
296-
(ext) => ext.name === parentNode!.type.name,
297-
)
298-
299-
if (
300-
parentExtension &&
301-
"config" in parentExtension &&
302-
parentExtension.config &&
303-
typeof (parentExtension.config as ExtendedNodeConfig)
304-
.getPlaceholders === "function"
305-
) {
306-
const placeholder = (
307-
parentExtension.config as ExtendedNodeConfig
308-
).getPlaceholders(node)
309-
if (placeholder) {
310-
return placeholder
311-
}
312-
}
313-
}
314-
315-
if (node.type.name === "heading") {
316-
return "Add a heading"
317-
}
318-
return "Add some text"
319-
},
320-
}),
321-
HorizontalRule,
322-
LearningResourceURLHandler,
323-
LearningResourceNode,
324-
TextAlign.configure({ types: ["heading", "paragraph"] }),
325-
TaskList,
326-
TaskItem.configure({ nested: true }),
327-
Highlight.configure({ multicolor: true }),
328-
TiptapTypography,
329-
Superscript,
330-
Subscript,
331-
Selection,
332-
Image,
333-
MediaEmbedNode,
334-
DividerNode,
335-
ArticleByLineInfoBarNode,
336-
ImageWithCaptionNode,
337-
MediaEmbedURLHandler,
338-
ImageNode.configure({
339-
accept: "image/*",
340-
maxSize: MAX_FILE_SIZE,
341-
limit: 3,
342-
upload: uploadHandler,
343-
onError: (error) => setUploadError(error.message),
344-
}),
345-
BannerNode,
346-
],
340+
extensions,
347341
})
348342

349343
useEffect(() => {
@@ -466,7 +460,11 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
466460
</StyledAlert>
467461
) : null}
468462
<LearningResourceDrawer />
469-
<TiptapEditor editor={editor} readOnly={readOnly} fullWidth />
463+
{readOnly ? (
464+
<TipTapViewer content={content} extensions={extensions} />
465+
) : (
466+
<TiptapEditor editor={editor} />
467+
)}
470468
</EditorContext.Provider>
471469
</LearningResourceProvider>
472470
</ArticleProvider>

0 commit comments

Comments
 (0)