extract ui components from playground; show spinner on keyboard-initiated plays

This commit is contained in:
Vyacheslav Matyukhin 2022-06-26 23:30:32 +03:00
parent 4fe30107a4
commit 87bb752c92
No known key found for this signature in database
GPG Key ID: 3D2A774C5489F96C
3 changed files with 349 additions and 311 deletions

View File

@ -1,9 +1,8 @@
import React, { FC, Fragment, useState, useEffect, useMemo } from "react"; import React, { FC, useState, useEffect, useMemo } from "react";
import { Path, useForm, UseFormRegister, useWatch } from "react-hook-form"; import { Path, useForm, UseFormRegister, useWatch } from "react-hook-form";
import * as yup from "yup"; import * as yup from "yup";
import { useMaybeControlledValue } from "../lib/hooks"; import { useMaybeControlledValue } from "../lib/hooks";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Tab } from "@headlessui/react";
import { import {
ChartSquareBarIcon, ChartSquareBarIcon,
CheckCircleIcon, CheckCircleIcon,
@ -25,6 +24,8 @@ import { JsonEditor } from "./JsonEditor";
import { ErrorAlert, SuccessAlert } from "./Alert"; import { ErrorAlert, SuccessAlert } from "./Alert";
import { SquiggleContainer } from "./SquiggleContainer"; import { SquiggleContainer } from "./SquiggleContainer";
import { Toggle } from "./ui/Toggle"; import { Toggle } from "./ui/Toggle";
import { Checkbox } from "./ui/Checkbox";
import { StyledTab } from "./ui/StyledTab";
interface PlaygroundProps { interface PlaygroundProps {
/** The initial squiggle string to put in the playground */ /** The initial squiggle string to put in the playground */
@ -49,9 +50,7 @@ interface PlaygroundProps {
showEditor?: boolean; showEditor?: boolean;
} }
const schema = yup const schema = yup.object({}).shape({
.object()
.shape({
sampleCount: yup sampleCount: yup
.number() .number()
.required() .required()
@ -77,76 +76,20 @@ const schema = yup
.min(10) .min(10)
.max(100) .max(100)
.default(50), .default(50),
showTypes: yup.boolean(), showTypes: yup.boolean().required(),
showControls: yup.boolean(), showControls: yup.boolean().required(),
showSummary: yup.boolean(), showSummary: yup.boolean().required(),
showEditor: yup.boolean(), showEditor: yup.boolean().required(),
logX: yup.boolean(), logX: yup.boolean().required(),
expY: yup.boolean(), expY: yup.boolean().required(),
showSettingsPage: yup.boolean().default(false), showSettingsPage: yup.boolean().default(false),
diagramStart: yup diagramStart: yup.number().required().positive().integer().default(0).min(0),
.number() diagramStop: yup.number().required().positive().integer().default(10).min(0),
.required() diagramCount: yup.number().required().positive().integer().default(20).min(2),
.positive() autoplay: yup.boolean().required(),
.integer() });
.default(0)
.min(0),
diagramStop: yup
.number()
.required()
.positive()
.integer()
.default(10)
.min(0),
diagramCount: yup
.number()
.required()
.positive()
.integer()
.default(20)
.min(2),
})
.required();
type StyledTabProps = { type FormFields = yup.InferType<typeof schema>;
name: string;
icon: (props: React.ComponentProps<"svg">) => JSX.Element;
};
const StyledTab: React.FC<StyledTabProps> = ({ name, icon: Icon }) => {
return (
<Tab as={Fragment}>
{({ selected }) => (
<button className="group flex rounded-md focus:outline-none focus-visible:ring-offset-gray-100">
<span
className={clsx(
"p-1 pl-2.5 pr-3.5 rounded-md flex items-center text-sm font-medium",
selected && "bg-white shadow-sm ring-1 ring-black ring-opacity-5"
)}
>
<Icon
className={clsx(
"-ml-0.5 mr-2 h-4 w-4",
selected
? "text-slate-500"
: "text-gray-400 group-hover:text-gray-900"
)}
/>
<span
className={clsx(
selected
? "text-gray-900"
: "text-gray-600 group-hover:text-gray-900"
)}
>
{name}
</span>
</span>
</button>
)}
</Tab>
);
};
const HeadedSection: FC<{ title: string; children: React.ReactNode }> = ({ const HeadedSection: FC<{ title: string; children: React.ReactNode }> = ({
title, title,
@ -187,155 +130,9 @@ function InputItem<T>({
); );
} }
function Checkbox<T>({ const SamplingSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({
name,
label,
register, register,
}: { }) => (
name: Path<T>;
label: string;
register: UseFormRegister<T>;
}) {
return (
<label className="flex items-center">
<input
type="checkbox"
{...register(name)}
className="form-checkbox focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
{/* Clicking on the div makes the checkbox lose focus while mouse button is pressed, leading to annoying blinking; I couldn't figure out how to fix this. */}
<div className="ml-3 text-sm font-medium text-gray-700">{label}</div>
</label>
);
}
const PlayControls: React.FC<{
autoplay: boolean;
isStale: boolean;
onAutoplayChange: (value: boolean) => void;
onPlay: () => void;
}> = ({ autoplay, isStale, onAutoplayChange, onPlay }) => {
const [playing, setPlaying] = useState(false);
const play = () => {
setPlaying(true);
};
// this is tricky; we need to render PlayControls first to make sure that the icon is spinning,
// and only then call onPlay (which freezes the UI)
useEffect(() => {
if (!autoplay && playing) {
onPlay();
setPlaying(false);
}
// don't add onPlay to the deps below
}, [autoplay, playing]); // eslint-disable-line react-hooks/exhaustive-deps
const CurrentPlayIcon = playing ? RefreshIcon : PlayIcon;
return (
<div className="flex space-x-1 items-center">
{autoplay ? null : (
<button onClick={play}>
<CurrentPlayIcon
className={clsx(
"w-8 h-8",
playing && "animate-spin",
isStale ? "text-indigo-500" : "text-gray-400"
)}
/>
</button>
)}
<Toggle
texts={["Autoplay", "Paused"]}
icons={[CheckCircleIcon, PauseIcon]}
status={autoplay}
onChange={onAutoplayChange}
/>
</div>
);
};
export const SquigglePlayground: FC<PlaygroundProps> = ({
defaultCode = "",
height = 500,
showTypes = false,
showControls = false,
showSummary = false,
logX = false,
expY = false,
code: controlledCode,
onCodeChange,
onSettingsChange,
showEditor = true,
}) => {
const [code, setCode] = useMaybeControlledValue({
value: controlledCode,
defaultValue: defaultCode,
onChange: onCodeChange,
});
const [importString, setImportString] = useState("{}");
const [imports, setImports] = useState({});
const [importsAreValid, setImportsAreValid] = useState(true);
const [renderedCode, setRenderedCode] = useState(""); // used only if autoplay is false
const {
register,
control,
setValue: setFormValue,
} = useForm({
resolver: yupResolver(schema),
defaultValues: {
sampleCount: 1000,
xyPointLength: 1000,
chartHeight: 150,
showTypes,
showControls,
logX,
expY,
showSummary,
showEditor,
leftSizePercent: 50,
showSettingsPage: false,
diagramStart: 0,
diagramStop: 10,
diagramCount: 20,
autoplay: true,
},
});
const vars = useWatch({
control,
});
useEffect(() => {
onSettingsChange?.(vars);
}, [vars, onSettingsChange]);
const env: environment = useMemo(
() => ({
sampleCount: Number(vars.sampleCount),
xyPointLength: Number(vars.xyPointLength),
}),
[vars.sampleCount, vars.xyPointLength]
);
const getChangeJson = (r: string) => {
setImportString(r);
try {
setImports(JSON.parse(r));
setImportsAreValid(true);
} catch (e) {
setImportsAreValid(false);
}
};
const manualPlay = () => {
if (vars.autoplay) return; // should we allow reruns even in autoplay mode?
setRenderedCode(code); // TODO - force play even if code hasn't changed
};
const samplingSettings = (
<div className="space-y-6 p-3 max-w-xl"> <div className="space-y-6 p-3 max-w-xl">
<div> <div>
<InputItem <InputItem
@ -360,15 +157,17 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
/> />
<div className="mt-2"> <div className="mt-2">
<Text> <Text>
When distributions are converted into PointSet shapes, we need to When distributions are converted into PointSet shapes, we need to know
know how many coordinates to use. how many coordinates to use.
</Text> </Text>
</div> </div>
</div> </div>
</div> </div>
); );
const viewSettings = ( const ViewSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({
register,
}) => (
<div className="space-y-6 p-3 divide-y divide-gray-200 max-w-xl"> <div className="space-y-6 p-3 divide-y divide-gray-200 max-w-xl">
<HeadedSection title="General Display Settings"> <HeadedSection title="General Display Settings">
<div className="space-y-4"> <div className="space-y-4">
@ -422,10 +221,10 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
<HeadedSection title="Function Display Settings"> <HeadedSection title="Function Display Settings">
<div className="space-y-6"> <div className="space-y-6">
<Text> <Text>
When displaying functions of single variables that return numbers When displaying functions of single variables that return numbers or
or distributions, we need to use defaults for the x-axis. We need distributions, we need to use defaults for the x-axis. We need to
to select a minimum and maximum value of x to sample, and a number select a minimum and maximum value of x to sample, and a number n of
n of the number of points to sample. the number of points to sample.
</Text> </Text>
<div className="space-y-4"> <div className="space-y-4">
<InputItem <InputItem
@ -453,7 +252,28 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
</div> </div>
); );
const inputVariableSettings = ( const InputVariablesSettings: React.FC<{
initialImports: any; // TODO - any json type
setImports: (imports: any) => void;
}> = ({ initialImports, setImports }) => {
const [importString, setImportString] = useState(() =>
JSON.stringify(initialImports)
);
const [importsAreValid, setImportsAreValid] = useState(true);
const onChange = (value: string) => {
setImportString(value);
let imports = {} as any;
try {
imports = JSON.parse(value);
setImportsAreValid(true);
} catch (e) {
setImportsAreValid(false);
}
setImports(imports);
};
return (
<div className="p-3 max-w-3xl"> <div className="p-3 max-w-3xl">
<HeadedSection title="Import Variables from JSON"> <HeadedSection title="Import Variables from JSON">
<div className="space-y-6"> <div className="space-y-6">
@ -465,7 +285,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
<div className="border border-slate-200 mt-6 mb-2"> <div className="border border-slate-200 mt-6 mb-2">
<JsonEditor <JsonEditor
value={importString} value={importString}
onChange={getChangeJson} onChange={onChange}
oneLine={false} oneLine={false}
showGutter={true} showGutter={true}
height={150} height={150}
@ -484,6 +304,130 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
</HeadedSection> </HeadedSection>
</div> </div>
); );
};
const PlayControls: React.FC<{
autoplay: boolean;
playing: boolean;
stale: boolean;
onAutoplayChange: (value: boolean) => void;
play: () => void;
}> = ({ autoplay, playing, stale, onAutoplayChange, play }) => {
const CurrentPlayIcon = playing ? RefreshIcon : PlayIcon;
return (
<div className="flex space-x-1 items-center">
{autoplay ? null : (
<button onClick={play}>
<CurrentPlayIcon
className={clsx(
"w-8 h-8",
playing && "animate-spin",
stale ? "text-indigo-500" : "text-gray-400"
)}
/>
</button>
)}
<Toggle
texts={["Autoplay", "Paused"]}
icons={[CheckCircleIcon, PauseIcon]}
status={autoplay}
onChange={onAutoplayChange}
/>
</div>
);
};
export const SquigglePlayground: FC<PlaygroundProps> = ({
defaultCode = "",
height = 500,
showTypes = false,
showControls = false,
showSummary = false,
logX = false,
expY = false,
code: controlledCode,
onCodeChange,
onSettingsChange,
showEditor = true,
}) => {
const [code, setCode] = useMaybeControlledValue({
value: controlledCode,
defaultValue: defaultCode,
onChange: onCodeChange,
});
const [renderedCode, setRenderedCode] = useState(""); // used only if autoplay is false
const [imports, setImports] = useState({});
const {
register,
control,
setValue: setFormValue,
} = useForm({
resolver: yupResolver(schema),
defaultValues: {
sampleCount: 1000,
xyPointLength: 1000,
chartHeight: 150,
showTypes,
showControls,
logX,
expY,
showSummary,
showEditor,
leftSizePercent: 50,
showSettingsPage: false,
diagramStart: 0,
diagramStop: 10,
diagramCount: 20,
autoplay: true,
},
});
const vars = useWatch({
control,
});
useEffect(() => {
onSettingsChange?.(vars);
}, [vars, onSettingsChange]);
const env: environment = useMemo(
() => ({
sampleCount: Number(vars.sampleCount),
xyPointLength: Number(vars.xyPointLength),
}),
[vars.sampleCount, vars.xyPointLength]
);
const [playing, setPlaying] = useState(false); // used in manual play mode only
// This part is tricky and fragile; we need to re-render first to make sure that the icon is spinning,
// and only then evaluate the squiggle code (which freezes the UI).
// Also note that `useEffect` execution order matters here.
// Hopefully it'll all go away after we make squiggle code evaluation async.
useEffect(() => {
if (renderedCode === code && playing) {
// It's not possible to put this after `setRenderedCode(code)` below because React would apply
// `setPlaying` and `setRenderedCode` together and spinning icon will disappear immediately.
setPlaying(false);
}
}, [renderedCode, code, playing]);
useEffect(() => {
if (!vars.autoplay && playing) {
setRenderedCode(code); // TODO - force play even if code hasn't changed
}
}, [vars.autoplay, code, playing]);
const play = () => {
setPlaying(true);
};
const manualPlay = () => {
if (vars.autoplay) return; // should we allow reruns even in autoplay mode?
setRenderedCode(code); // TODO - force play even if code hasn't changed
};
const squiggleChart = ( const squiggleChart = (
<SquiggleChart <SquiggleChart
@ -508,7 +452,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
<CodeEditor <CodeEditor
value={code ?? ""} value={code ?? ""}
onChange={setCode} onChange={setCode}
onSubmit={manualPlay} onSubmit={play}
oneLine={false} oneLine={false}
showGutter={true} showGutter={true}
height={height - 1} height={height - 1}
@ -519,12 +463,21 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
); );
const tabs = ( const tabs = (
<Tab.Panels> <StyledTab.Panels>
<Tab.Panel>{firstTab}</Tab.Panel> <StyledTab.Panel>{firstTab}</StyledTab.Panel>
<Tab.Panel>{samplingSettings}</Tab.Panel> <StyledTab.Panel>
<Tab.Panel>{viewSettings}</Tab.Panel> <SamplingSettings register={register} />
<Tab.Panel>{inputVariableSettings}</Tab.Panel> </StyledTab.Panel>
</Tab.Panels> <StyledTab.Panel>
<ViewSettings register={register} />
</StyledTab.Panel>
<StyledTab.Panel>
<InputVariablesSettings
initialImports={imports}
setImports={setImports}
/>
</StyledTab.Panel>
</StyledTab.Panels>
); );
const withEditor = ( const withEditor = (
@ -538,10 +491,10 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
return ( return (
<SquiggleContainer> <SquiggleContainer>
<Tab.Group> <StyledTab.Group>
<div className="pb-4"> <div className="pb-4">
<div className="flex justify-between items-center mt-2"> <div className="flex justify-between items-center mt-2">
<Tab.List className="flex w-fit p-0.5 rounded-md bg-slate-100 hover:bg-slate-200"> <StyledTab.List>
<StyledTab <StyledTab
name={vars.showEditor ? "Code" : "Display"} name={vars.showEditor ? "Code" : "Display"}
icon={vars.showEditor ? CodeIcon : EyeIcon} icon={vars.showEditor ? CodeIcon : EyeIcon}
@ -549,11 +502,12 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
<StyledTab name="Sampling Settings" icon={CogIcon} /> <StyledTab name="Sampling Settings" icon={CogIcon} />
<StyledTab name="View Settings" icon={ChartSquareBarIcon} /> <StyledTab name="View Settings" icon={ChartSquareBarIcon} />
<StyledTab name="Input Variables" icon={CurrencyDollarIcon} /> <StyledTab name="Input Variables" icon={CurrencyDollarIcon} />
</Tab.List> </StyledTab.List>
<PlayControls <PlayControls
autoplay={vars.autoplay || false} autoplay={vars.autoplay || false}
isStale={!vars.autoplay && renderedCode !== code} stale={!vars.autoplay && renderedCode !== code}
onPlay={manualPlay} play={play}
playing={playing}
onAutoplayChange={(newValue) => { onAutoplayChange={(newValue) => {
if (!newValue) setRenderedCode(code); if (!newValue) setRenderedCode(code);
setFormValue("autoplay", newValue); setFormValue("autoplay", newValue);
@ -562,7 +516,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
</div> </div>
{vars.showEditor ? withEditor : withoutEditor} {vars.showEditor ? withEditor : withoutEditor}
</div> </div>
</Tab.Group> </StyledTab.Group>
</SquiggleContainer> </SquiggleContainer>
); );
}; };

View File

@ -0,0 +1,24 @@
import React from "react";
import { Path, UseFormRegister } from "react-hook-form";
export function Checkbox<T>({
name,
label,
register,
}: {
name: Path<T>;
label: string;
register: UseFormRegister<T>;
}) {
return (
<label className="flex items-center">
<input
type="checkbox"
{...register(name)}
className="form-checkbox focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
{/* Clicking on the div makes the checkbox lose focus while mouse button is pressed, leading to annoying blinking; I couldn't figure out how to fix this. */}
<div className="ml-3 text-sm font-medium text-gray-700">{label}</div>
</label>
);
}

View File

@ -0,0 +1,60 @@
import React, { Fragment } from "react";
import { Tab } from "@headlessui/react";
import clsx from "clsx";
type StyledTabProps = {
name: string;
icon: (props: React.ComponentProps<"svg">) => JSX.Element;
};
type StyledTabType = React.FC<StyledTabProps> & {
List: React.FC<{ children: React.ReactNode }>;
Group: typeof Tab.Group;
Panels: typeof Tab.Panels;
Panel: typeof Tab.Panel;
};
export const StyledTab: StyledTabType = ({ name, icon: Icon }) => {
return (
<Tab as={Fragment}>
{({ selected }) => (
<button className="group flex rounded-md focus:outline-none focus-visible:ring-offset-gray-100">
<span
className={clsx(
"p-1 pl-2.5 pr-3.5 rounded-md flex items-center text-sm font-medium",
selected && "bg-white shadow-sm ring-1 ring-black ring-opacity-5"
)}
>
<Icon
className={clsx(
"-ml-0.5 mr-2 h-4 w-4",
selected
? "text-slate-500"
: "text-gray-400 group-hover:text-gray-900"
)}
/>
<span
className={clsx(
selected
? "text-gray-900"
: "text-gray-600 group-hover:text-gray-900"
)}
>
{name}
</span>
</span>
</button>
)}
</Tab>
);
};
StyledTab.List = ({ children }) => (
<Tab.List className="flex w-fit p-0.5 rounded-md bg-slate-100 hover:bg-slate-200">
{children}
</Tab.List>
);
StyledTab.Group = Tab.Group;
StyledTab.Panels = Tab.Panels;
StyledTab.Panel = Tab.Panel;