-
Notifications
You must be signed in to change notification settings - Fork 350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Type the correct
prop passed to InteractiveGraphEditor
#1599
Changes from all commits
f1ea25f
2327890
f9c447a
05a22d1
f70b372
0003cd0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
"@khanacademy/perseus": patch | ||
"@khanacademy/perseus-editor": patch | ||
--- | ||
|
||
Internal: improve type safety of interactive graph editor |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,8 @@ | ||
import {vector as kvector} from "@khanacademy/kmath"; | ||
import { | ||
components, | ||
interactiveSizes, | ||
InteractiveGraphWidget, | ||
interactiveSizes, | ||
SizingUtils, | ||
Util, | ||
} from "@khanacademy/perseus"; | ||
|
@@ -11,8 +11,10 @@ import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; | |
import {Checkbox} from "@khanacademy/wonder-blocks-form"; | ||
import {spacing} from "@khanacademy/wonder-blocks-tokens"; | ||
import {LabelSmall} from "@khanacademy/wonder-blocks-typography"; | ||
import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; | ||
import {StyleSheet} from "aphrodite"; | ||
import * as React from "react"; | ||
import invariant from "tiny-invariant"; | ||
import _ from "underscore"; | ||
|
||
import LabeledRow from "../../components/graph-locked-figures/labeled-row"; | ||
|
@@ -27,10 +29,11 @@ import {shouldShowStartCoordsUI} from "../../components/util"; | |
import {parsePointCount} from "../../util/points"; | ||
|
||
import type { | ||
PerseusImageBackground, | ||
PerseusInteractiveGraphWidgetOptions, | ||
APIOptionsWithDefaults, | ||
LockedFigure, | ||
PerseusImageBackground, | ||
PerseusInteractiveGraphWidgetOptions, | ||
PerseusGraphType, | ||
} from "@khanacademy/perseus"; | ||
import type {PropsFor} from "@khanacademy/wonder-blocks-core"; | ||
|
||
|
@@ -55,6 +58,8 @@ const POLYGON_SIDES = _.map(_.range(3, 13), function (value) { | |
}); | ||
|
||
type Range = [min: number, max: number]; | ||
type PerseusGraphTypePolygon = Extract<PerseusGraphType, {type: "polygon"}>; | ||
type PerseusGraphTypeAngle = Extract<PerseusGraphType, {type: "angle"}>; | ||
|
||
export type Props = { | ||
apiOptions: APIOptionsWithDefaults; | ||
|
@@ -119,7 +124,8 @@ export type Props = { | |
* on the state of the interactive graph previewed at the bottom of the | ||
* editor page. | ||
*/ | ||
correct: any; // TODO(jeremy) | ||
// TODO(LEMS-2344): make the type of `correct` more specific | ||
correct: PerseusGraphType; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if this is the right time to think about this, but do we want Personally, I think it makes more sense for Any thoughts on that? (@benchristel @jeremywiebe) I could see this potentially turning into a larger discussion, so I think it could also be worth taking this offline. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it should be typed as what we explicitly save today. I suspect that's not the full There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that would be better. I think it's a bigger effort though (I'd like to analyze our existing content to figure out what values for |
||
/** | ||
* The locked figures to display in the graph area. | ||
* Locked figures are graph elements (points, lines, line segmeents, | ||
|
@@ -168,14 +174,6 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
}, | ||
}; | ||
|
||
changeMatchType = (newValue) => { | ||
const correct = { | ||
...this.props.correct, | ||
match: newValue, | ||
}; | ||
this.props.onChange({correct: correct}); | ||
}; | ||
|
||
changeStartCoords = (coords) => { | ||
if (!this.props.graph?.type) { | ||
return; | ||
|
@@ -294,17 +292,16 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
showTooltips: this.props.showTooltips, | ||
lockedFigures: this.props.lockedFigures, | ||
trackInteraction: function () {}, | ||
onChange: (newProps: InteractiveGraphProps) => { | ||
onChange: ({graph: newGraph}: InteractiveGraphProps) => { | ||
let correct = this.props.correct; | ||
// @ts-expect-error - TS2532 - Object is possibly 'undefined'. | ||
if (correct.type === newProps.graph.type) { | ||
correct = { | ||
...correct, | ||
...newProps.graph, | ||
}; | ||
// TODO(benchristel): can we improve the type of onChange | ||
// so this invariant isn't necessary? | ||
invariant(newGraph != null); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it better to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The invariant is a concise way of stating that we think a particular case should be impossible (in this case, newGraph should never be null) and throwing an exception if the impossible thing happens. A conditional implies that we're handling an expected case. |
||
if (correct.type === newGraph.type) { | ||
correct = mergeGraphs(correct, newGraph); | ||
} else { | ||
// Clear options from previous graph | ||
correct = newProps.graph; | ||
correct = newGraph; | ||
} | ||
this.props.onChange({ | ||
correct: correct, | ||
|
@@ -387,19 +384,27 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
} | ||
placeholder="" | ||
onChange={(newValue) => { | ||
const graph = { | ||
...this.props.correct, | ||
invariant( | ||
this.props.graph?.type === "polygon", | ||
); | ||
const updates = { | ||
numSides: parsePointCount(newValue), | ||
coords: null, | ||
// reset the snap for UNLIMITED, which | ||
// only supports "grid" | ||
// From: D6578 | ||
snapTo: "grid", | ||
}; | ||
} as const; | ||
|
||
this.props.onChange({ | ||
correct: graph, | ||
graph: graph, | ||
correct: { | ||
...this.props.correct, | ||
...updates, | ||
}, | ||
graph: { | ||
...this.props.graph, | ||
...updates, | ||
}, | ||
}); | ||
}} | ||
style={styles.singleSelectShort} | ||
|
@@ -422,15 +427,29 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
// Never uses placeholder, always has value | ||
placeholder="" | ||
onChange={(newValue) => { | ||
const graph = { | ||
...this.props.correct, | ||
snapTo: newValue, | ||
invariant( | ||
this.props.correct.type === "polygon", | ||
`Expected correct answer type to be polygon, but got ${this.props.correct.type}`, | ||
); | ||
invariant( | ||
this.props.graph?.type === "polygon", | ||
`Expected graph type to be polygon, but got ${this.props.graph?.type}`, | ||
); | ||
|
||
const updates = { | ||
snapTo: newValue as PerseusGraphTypePolygon["snapTo"], | ||
coords: null, | ||
}; | ||
} as const; | ||
|
||
this.props.onChange({ | ||
correct: graph, | ||
graph: graph, | ||
correct: { | ||
...this.props.correct, | ||
...updates, | ||
}, | ||
graph: { | ||
...this.props.graph, | ||
...updates, | ||
}, | ||
}); | ||
}} | ||
style={styles.singleSelectShort} | ||
|
@@ -476,6 +495,11 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
} | ||
onChange={() => { | ||
if (this.props.graph?.type === "polygon") { | ||
invariant( | ||
this.props.correct.type === | ||
"polygon", | ||
`Expected graph type to be polygon, but got ${this.props.correct.type}`, | ||
); | ||
this.props.onChange({ | ||
correct: { | ||
...this.props.correct, | ||
|
@@ -507,7 +531,10 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
!!this.props.correct?.showSides | ||
} | ||
onChange={() => { | ||
if (this.props.graph?.type === "polygon") { | ||
if ( | ||
this.props.graph?.type === "polygon" && | ||
this.props.correct.type === "polygon" | ||
) { | ||
this.props.onChange({ | ||
correct: { | ||
...this.props.correct, | ||
|
@@ -581,7 +608,24 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
<LabeledRow label="Student answer must"> | ||
<SingleSelect | ||
selectedValue={this.props.correct.match || "exact"} | ||
onChange={this.changeMatchType} | ||
onChange={(newValue) => { | ||
invariant( | ||
this.props.correct.type === "polygon", | ||
`Expected graph type to be polygon, but got ${this.props.correct.type}`, | ||
); | ||
const correct = { | ||
...this.props.correct, | ||
// TODO(benchristel): this cast is necessary | ||
// because "exact" is not actually a valid | ||
// value for `match`; a value of undefined | ||
// means exact matching. The code happens | ||
// to work because "exact" falls through | ||
// to the correct else branch in | ||
// InteractiveGraph.validate() | ||
match: newValue as PerseusGraphTypePolygon["match"], | ||
}; | ||
this.props.onChange({correct}); | ||
}} | ||
// Never uses placeholder, always has value | ||
placeholder="" | ||
style={styles.singleSelectShort} | ||
|
@@ -644,7 +688,21 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
<LabeledRow label="Student answer must"> | ||
<SingleSelect | ||
selectedValue={this.props.correct.match || "exact"} | ||
onChange={this.changeMatchType} | ||
onChange={(newValue) => { | ||
this.props.onChange({ | ||
correct: { | ||
...this.props.correct, | ||
// TODO(benchristel): this cast is necessary | ||
// because "exact" is not actually a valid | ||
// value for `match`; a value of undefined | ||
// means exact matching. The code happens | ||
// to work because "exact" falls through | ||
// to the correct else branch in | ||
// InteractiveGraph.validate() | ||
match: newValue as PerseusGraphTypeAngle["match"], | ||
}, | ||
}); | ||
}} | ||
// Never uses placeholder, always has value | ||
placeholder="" | ||
style={styles.singleSelectShort} | ||
|
@@ -694,6 +752,54 @@ class InteractiveGraphEditor extends React.Component<Props> { | |
} | ||
} | ||
|
||
// Merges two graphs that have the same `type`. Properties defined in `b` | ||
// overwrite properties of the same name in `a`. Throws an exception if the | ||
// types are different or not recognized. | ||
function mergeGraphs( | ||
a: PerseusGraphType, | ||
b: PerseusGraphType, | ||
): PerseusGraphType { | ||
if (a.type !== b.type) { | ||
throw new Error( | ||
`Cannot merge graphs with different types (${a.type} and ${b.type})`, | ||
); | ||
} | ||
switch (a.type) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this switch statement if there's already a check for I feel like we should just be able to return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, TypeScript can't figure it out. |
||
case "angle": | ||
invariant(b.type === "angle"); | ||
return {...a, ...b}; | ||
case "circle": | ||
invariant(b.type === "circle"); | ||
return {...a, ...b}; | ||
case "linear": | ||
invariant(b.type === "linear"); | ||
return {...a, ...b}; | ||
case "linear-system": | ||
invariant(b.type === "linear-system"); | ||
return {...a, ...b}; | ||
case "point": | ||
invariant(b.type === "point"); | ||
return {...a, ...b}; | ||
case "polygon": | ||
invariant(b.type === "polygon"); | ||
return {...a, ...b}; | ||
case "quadratic": | ||
invariant(b.type === "quadratic"); | ||
return {...a, ...b}; | ||
case "ray": | ||
invariant(b.type === "ray"); | ||
return {...a, ...b}; | ||
case "segment": | ||
invariant(b.type === "segment"); | ||
return {...a, ...b}; | ||
case "sinusoid": | ||
invariant(b.type === "sinusoid"); | ||
return {...a, ...b}; | ||
default: | ||
throw new UnreachableCaseError(a); | ||
} | ||
} | ||
|
||
const styles = StyleSheet.create({ | ||
singleSelectShort: { | ||
// Non-standard spacing, but it's the smallest we can go | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉