Skip to content

Commit

Permalink
Perseus Editor: Add ContentPreview component (#1521)
Browse files Browse the repository at this point in the history
## Summary:

This PR introduces a new editor component: `ContentPreview`. It is a thin wrapper around `ServerItemRenderer` which provides an easy way for editors to preview a given Perseus Item. 

It should be noted that due to current Perseus styling, the preview will _not_ match production 100%. This is because Perseus styles are adjusted based `@media` queries on device width.

<img width="768" alt="image" src="https://github.com/user-attachments/assets/6b0a9369-f814-446f-9ce5-0f3c6db94518">

Issue: LEMS-1809

## Test plan:

View the prevew in Storybook. `yarn start` ==> http://localhost:6006/?path=/docs/perseuseditor-content-preview--docs

Author: jeremywiebe

Reviewers: jeremywiebe, mpolyak, #perseus, mahtabsabet

Required Reviewers:

Approved By: mpolyak

Checks: ✅ Upload Coverage (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: #1521
  • Loading branch information
jeremywiebe authored Aug 15, 2024
1 parent 6db145f commit a9292af
Show file tree
Hide file tree
Showing 11 changed files with 370 additions and 119 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-cobras-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Migrate Lint component to use WonderBlocks ToolTip
5 changes: 5 additions & 0 deletions .changeset/quiet-glasses-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": minor
---

Add ContentPreview component
5 changes: 5 additions & 0 deletions .changeset/wise-cougars-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-dev-ui": patch
---

Update vite config to alias `/strings` expots to correct strings.ts file per package
36 changes: 35 additions & 1 deletion dev/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,41 @@ const packageAliases = {};
glob.sync(resolve(__dirname, "../packages/*/package.json")).forEach(
(packageJsonPath) => {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
packageAliases[pkg.name] = join(dirname(packageJsonPath), pkg.source);

// "exports" is the more modern way to declare package exports. Some,
// but not all, Perseus packages declare "exports".
if ("exports" in pkg) {
// Not all packages export strings, but for those that do we need
// to set up an alias so Vite properly resolves them.
// Eg `import {strings, mockStrings} from "@khanacademy/perseus/strings";`
// And MOST IMPORTANTLY, this alias _must_ precede the main
// import, otherwise Vite will just use the main export and tack
// `/strings` onto the end, resulting in a path like this:
// `packages/perseus/src/index.ts/strings`
const stringsSource = pkg.exports["./strings"]?.source;
if (stringsSource != null) {
packageAliases[`${pkg.name}/strings`] = join(
dirname(packageJsonPath),
stringsSource,
);
}

const mainSource = pkg.exports["."]?.source;
if (mainSource == null) {
throw new Error(
`Package declares 'exports', but not provide a main export (exports["."])`,
);
}
packageAliases[pkg.name] = join(
dirname(packageJsonPath),
mainSource,
);
} else {
packageAliases[pkg.name] = join(
dirname(packageJsonPath),
pkg.source,
);
}
},
);

Expand Down
1 change: 1 addition & 0 deletions packages/perseus-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"dependencies": {
"@khanacademy/kas": "^0.3.11",
"@khanacademy/keypad-context": "^1.0.0",
"@khanacademy/kmath": "^0.1.13",
"@khanacademy/math-input": "^21.0.0",
"@khanacademy/perseus": "^28.2.0",
Expand Down
101 changes: 101 additions & 0 deletions packages/perseus-editor/src/__stories__/content-preview.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {View} from "@khanacademy/wonder-blocks-core";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {useState} from "react";

import {articleWithImages} from "../../../perseus/src/__testdata__/article-renderer.testdata";
import {mockStrings} from "../../../perseus/src/strings";
import {question} from "../../../perseus/src/widgets/__testdata__/radio.testdata";
import DeviceFramer from "../components/device-framer";
import ViewportResizer from "../components/viewport-resizer";
import ContentPreview from "../content-preview";

import type {DeviceType} from "@khanacademy/perseus";
import type {Meta, StoryObj} from "@storybook/react";

import "../styles/perseus-editor.less";

const PreviewWrapper = (props) => {
const [previewDevice, setPreviewDevice] = useState<DeviceType>("phone");

return (
<View>
<ViewportResizer
deviceType={previewDevice}
onViewportSizeChanged={setPreviewDevice}
/>
<DeviceFramer nochrome={false} deviceType={previewDevice}>
<ContentPreview {...props} />
</DeviceFramer>
</View>
);
};

const meta: Meta<typeof ContentPreview> = {
title: "PerseusEditor/Content Preview",
component: ContentPreview,
args: {
strings: mockStrings,
},
decorators: [
(Story) => (
<View style={{margin: spacing.xxSmall_6}}>
<Story />
</View>
),
],
render: (props) => <PreviewWrapper {...props} />,
};

export default meta;
type Story = StoryObj<typeof ContentPreview>;

export const Exercise: Story = {
args: {
question,
},
};

export const Article: Story = {
args: {
question: articleWithImages,
},
};

export const WithLintErrors: Story = {
args: {
linterContext: {
contentType: "exercise",
highlightLint: true,
stack: [],
paths: [],
},
question: {
content: `# H1s bad
Here is some unclosed math: $1+1=3
We should use \`\\dfrac{}\` instead of \`\\frac{}\`: $\\frac{3}{5}$
What is the best color in the world?
[[☃ radio 1]]`,
widgets: {
"radio 1": {
type: "radio",
options: {
choices: [
{content: "Red"},
{content: "# Green"},
{content: "Blue", correct: true},
{
content: "None of these!",
isNoneOfTheAbove: true,
},
],
},
},
},
images: {},
},
},
};
91 changes: 91 additions & 0 deletions packages/perseus-editor/src/content-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
KeypadContext,
StatefulKeypadContextProvider,
} from "@khanacademy/keypad-context";
import {MobileKeypad} from "@khanacademy/math-input";
import {
Renderer,
constants,
type APIOptions,
type DeviceType,
type PerseusRenderer,
} from "@khanacademy/perseus";
import {View} from "@khanacademy/wonder-blocks-core";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {StyleSheet} from "aphrodite";

import type {LinterContextProps} from "@khanacademy/perseus-linter";
import type {PropsFor} from "@khanacademy/wonder-blocks-core";

/**
* The `ContentPreview` component provides a simple preview system for Perseus
* Content. Due to how Persus styles are built, the preview styling matches the
* current device based on the viewport width (using `@media` queries for
* `min-width` and `max-width`).
*
* The preview will render the mobile variant (styling and layout) when the
* `previewDevice` is phone or tablet. Note that the styling cannot be matched
* 100% due to the above `@media` query limitation.
*/
function ContentPreview({
question,
apiOptions,
seamless,
linterContext,
legacyPerseusLint,
previewDevice,
strings,
}: {
question?: PerseusRenderer;
apiOptions?: APIOptions;
seamless?: boolean;
linterContext?: LinterContextProps;
legacyPerseusLint?: ReadonlyArray<string>;
previewDevice: DeviceType;
strings: PropsFor<typeof Renderer>["strings"];
}) {
const isMobile = previewDevice !== "desktop";

const className = isMobile ? "perseus-mobile" : "";

return (
<View
className={`framework-perseus ${className}`}
style={[styles.container, !seamless ? styles.gutter : undefined]}
>
<StatefulKeypadContextProvider>
<KeypadContext.Consumer>
{({setKeypadActive, keypadElement, setKeypadElement}) => (
<>
<Renderer
strings={strings}
apiOptions={{...apiOptions, isMobile}}
keypadElement={keypadElement}
linterContext={linterContext}
legacyPerseusLint={legacyPerseusLint}
{...question}
/>

<MobileKeypad
onAnalyticsEvent={() => Promise.resolve()}
onDismiss={() => setKeypadActive(false)}
onElementMounted={setKeypadElement}
/>
</>
)}
</KeypadContext.Consumer>
</StatefulKeypadContextProvider>
</View>
);
}

const styles = StyleSheet.create({
container: {
padding: spacing.xxxSmall_4,
containerType: "inline-size",
containerName: "perseus-root",
},
gutter: {marginRight: constants.lintGutterWidth},
});

export default ContentPreview;
1 change: 1 addition & 0 deletions packages/perseus-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {default as StructuredItemDiff} from "./diffs/structured-item-diff";
export {default as EditorPage} from "./editor-page";
export {default as Editor} from "./editor";
export {default as i18n} from "./i18n";
export {default as ContentPreview} from "./content-preview";
export {default as IframeContentRenderer} from "./iframe-content-renderer";
export {default as MultiRendererEditor} from "./multirenderer-editor";
export {default as StatefulEditorPage} from "./stateful-editor-page";
Expand Down
90 changes: 90 additions & 0 deletions packages/perseus/src/__testdata__/article-renderer.testdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,96 @@ export const singleSectionArticle: PerseusRenderer = {
},
};

export const articleWithImages: PerseusRenderer = {
content:
"The word \"radiation\" sometimes gets a bad rap. People often associate radiation with something dangerous or scary, without really knowing what it is. In reality, we're surrounded by radiation all the time. \n\n**Radiation** is energy that travels through space (not just \"outer space\"—any space). Radiation can also interact with matter. How radiation interacts with matter depends on the type of radiation and the type of matter.\n\nRadiation comes in many forms, one of which is *electromagnetic radiation*. Without electromagnetic radiation life on Earth would not be possible, nor would most modern technologies.\n\n[[☃ image 13]]\n\nLet's take a closer look at this important and fascinating type of radiation.\n\n##Electromagnetic radiation\n\nAs the name suggests, **electromagnetic (EM) radiation** is energy transferred by *electromagnetic fields* oscillating through space.\n\nEM radiation is strange—it has both wave and particle properties. Let's take a look at both.\n\n###Electromagnetic waves\n\nAn animated model of an EM wave is shown below.\n[[☃ image 1]]\nThe electric field $(\\vec{\\textbf{E}})$ is shown in $\\color{blue}\\textbf{blue}$, and the magnetic field $(\\vec{\\textbf{B}})$ is shown in $\\color{red}\\textbf{red}$. They're perpendicular to each other.\n\nA changing electric field creates a magnetic field, and a changing magnetic field creates an electric field. So, once the EM wave is generated it propagates itself through space!\n\nAs with any wave, EM waves have wavelength, frequency, and speed. The wave model of EM radiation works best on large scales. But what about the atomic scale?\n\n###Photons\n\nAt the quantum level, EM radiation exists as particles. A particle of EM radiation is called a **photon**.\n\nWe can think of photons as wave *packets*—tiny bundles of EM radiation containing specific amounts of energy. Photons are visually represented using the following symbol.\n\n[[☃ image 3]]\n\nAll EM radiation, whether modeled as waves or photons, travels at the **speed of light** $\\textbf{(c)}$ in a vacuum: \n\n$\\text{c}=3\\times10^8\\space\\pu{m/s}=300{,}000{,}000\\space\\pu{m/s}$\n\nBut, EM radiation travels at a slower speed in matter, such as water or glass.",
images: {},
widgets: {
"image 13": {
type: "image",
alignment: "block",
static: false,
graded: true,
options: {
static: false,
title: "",
range: [
[0, 10],
[0, 10],
],
box: [600, 254],
backgroundImage: {
url: "https://cdn.kastatic.org/ka-content-images/358a87c20ab6ee70447f5fcb547010f69986828e.jpg",
width: 600,
height: 254,
},
labels: [],
alt: "From space, the sun appears over Earth's horizon, illuminating the atmosphere as a blue layer above Earth. Above the atmosphere, space appears black.",
caption:
"*Sunrise photo from the International Space Station. Earth's atmosphere scatters electromagnetic radiation from the sun, producing a bright sky during the day.*",
},
version: {
major: 0,
minor: 0,
},
},
"image 1": {
type: "image",
alignment: "block",
static: false,
graded: true,
options: {
static: false,
title: "",
range: [
[0, 10],
[0, 10],
],
box: [627, 522],
backgroundImage: {
url: "https://cdn.kastatic.org/ka-content-images/8100369eaf3b581d4e7bfc9f1062625309def486.gif",
width: 627,
height: 522,
},
labels: [],
alt: "An animation shows a blue electric field arrow oscillating up and down. Connected to the base of the electric field arrow is a magnetic field arrow, which oscillates from side to side. The two fields oscillate in unison: when one extends the other extends too, creating a repeating wave pattern.",
caption: "",
},
version: {
major: 0,
minor: 0,
},
},
"image 3": {
type: "image",
alignment: "block",
static: false,
graded: true,
options: {
static: false,
title: "",
range: [
[0, 10],
[0, 10],
],
box: [350, 130],
backgroundImage: {
url: "https://cdn.kastatic.org/ka-content-images/74edeeb6c6605a4e854e3a3e9db69c01dcf5508f.svg",
width: 350,
height: 130,
},
labels: [],
alt: "A squiggly curve drawn from left to right. The right end of the curve has an arrow point. The curve begins with a small amount of wiggle on the left, which grows in amplitude in the middle and then decreases again on the right. The result is a small wave packet.",
caption: "",
},
version: {
major: 0,
minor: 0,
},
},
},
};

export const passageArticle: PerseusRenderer = {
content:
"###Group/Pair Activity \n\nThis passage is adapted from Ed Yong, “Turtles Use the Earth’s Magnetic Field as Global GPS.” ©2011 by Kalmbach Publishing Co.\n\n[[☃ passage 1]]\n\n**Question 9**\n\nThe passage most strongly suggests that Adelita used which of the following to navigate her 9,000-mile journey?\n\nA) The current of the North Atlantic gyre\n\nB) Cues from electromagnetic coils designed by Putman and Lohmann\n\nC) The inclination and intensity of Earth’s magnetic field\n\nD) A simulated “magnetic signature” configured by Lohmann\n\n10) Which choice provides the best evidence for the answer to the previous question?\n\nA) Lines 1–2 (“In 1996...way”)\n\nB) Lines 20–21 (“Using...surface”)\n\nC) Lines 36–37 (“In the wild...stars”)\n\nD) Lines 43–45 (“Neither...it is”)\n\n**Question 12** \n\nBased on the passage, which choice best describes the relationship between Putman’s and Lohmann’s research?\n\nA) Putman’s research contradicts Lohmann’s.\n\nB) Putman’s research builds on Lohmann’s.\n\nC) Lohmann’s research confirms Putman’s.\n\nD) Lohmann’s research corrects Putman’s.",
Expand Down
8 changes: 7 additions & 1 deletion packages/perseus/src/__tests__/server-item-renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ describe("server item renderer", () => {
).not.toBeInTheDocument();
});

it("should show linting errors when highlightLint is true", () => {
it("should show linting errors when highlightLint is true", async () => {
// Arrange and Act
renderQuestion(itemWithLintingError, undefined, {
linterContext: {
Expand All @@ -599,6 +599,12 @@ describe("server item renderer", () => {
},
});

// Linting errors are surfaced as a link with a warning or error
// icon inside them. We need to click on it to open the tooltip
// that contains the error message.
const lintIcon = screen.getByRole("link");
await userEvent.click(lintIcon);

expect(
screen.getByText("Don't use level-1 headings", {exact: false}),
).toBeInTheDocument();
Expand Down
Loading

0 comments on commit a9292af

Please sign in to comment.