New sharing and presentation feature (#31)

* Improved presentation modals and presentation style
* Cleaned up unused learnings. Improved logic for showing the right phases.
This commit is contained in:
Graham McNicoll
2021-07-29 09:44:42 -07:00
committed by GitHub
parent 5bd8c7ad85
commit 3f8082dd66
28 changed files with 1745 additions and 1048 deletions

View File

@@ -345,6 +345,7 @@ app.get(
"/experiments/frequency/month/:num",
experimentsController.getExperimentsFrequencyMonth
);
app.get("/experiments/snapshots/", experimentsController.getSnapshots);
app.get("/experiment/:id", experimentsController.getExperiment);
app.get("/snapshot/:id/status", experimentsController.getSnapshotStatus);
app.post("/snapshot/:id/cancel", experimentsController.cancelSnapshot);
@@ -444,6 +445,7 @@ app.delete("/webhook/:id", organizationsController.deleteWebhook);
// Presentations
app.get("/presentations", presentationController.getPresentations);
app.post("/presentation", presentationController.postPresentation);
app.get("/presentation/preview", presentationController.getPresentationPreview);
app.get("/presentation/:id", presentationController.getPresentation);
app.post("/presentation/:id", presentationController.updatePresentation);
app.delete("/presentation/:id", presentationController.deletePresentation);

View File

@@ -19,6 +19,7 @@ import { createLearning } from "../services/learnings";
import { createPresentation } from "../services/presentations";
import { DataSourceModel } from "../models/DataSourceModel";
import { POSTGRES_TEST_CONN } from "../util/secrets";
import { PresentationSlide } from "../../types/presentation";
export async function getOrganizations(req: AuthRequest, res: Response) {
if (!req.admin) {
@@ -409,12 +410,15 @@ export async function addSampleData(req: AuthRequest, res: Response) {
});
// Example presentation
const exp: PresentationSlide = {
id: evidence[0],
type: "experiment",
};
await createPresentation({
title: "A/B Test Review",
experimentIds: evidence,
title: "Example A/B Test Review",
slides: [exp],
organization: org.id,
description: "",
options: {},
});
res.json({

View File

@@ -24,6 +24,7 @@ import {
MetricStats,
} from "../../types/metric";
import { ExperimentModel } from "../models/ExperimentModel";
import { ExperimentSnapshotDocument } from "../models/ExperimentSnapshotModel";
import {
getDataSourceById,
getSourceIntegrationObject,
@@ -165,7 +166,7 @@ export async function getExperiment(req: AuthRequest, res: Response) {
async function _getSnapshot(
organization: string,
id: string,
phase: string,
phase?: string,
dimension?: string,
withResults: boolean = true
) {
@@ -179,6 +180,11 @@ async function _getSnapshot(
throw new Error("You do not have access to view this experiment");
}
if (!phase) {
// get the latest phase:
phase = String(experiment.phases.length - 1);
}
return await getLatestSnapshot(
experiment.id,
parseInt(phase),
@@ -236,6 +242,31 @@ export async function getSnapshot(req: AuthRequest, res: Response) {
});
}
export async function getSnapshots(req: AuthRequest, res: Response) {
const idsString = (req.query?.ids as string) || "";
if (!idsString.length) {
res.status(200).json({
status: 200,
snapshots: [],
});
return;
}
const ids = idsString.split(",");
let snapshotsPromises: Promise<ExperimentSnapshotDocument>[] = [];
snapshotsPromises = ids.map(async (i) => {
return await _getSnapshot(req.organization.id, i);
});
const snapshots = await Promise.all(snapshotsPromises);
res.status(200).json({
status: 200,
snapshots,
});
return;
}
/**
* Creates a new experiment
* @param req

View File

@@ -10,9 +10,7 @@ import {
getExperimentsByIds,
getLatestSnapshot,
} from "../services/experiments";
import { getLearningsByExperimentIds } from "../services/learnings";
import { userHasAccess } from "../services/organizations";
import { LearningInterface } from "../../types/insight";
import { ExperimentInterface } from "../../types/experiment";
import { ExperimentSnapshotInterface } from "../../types/experiment-snapshot";
import { PresentationInterface } from "../../types/presentation";
@@ -22,23 +20,9 @@ export async function getPresentations(req: AuthRequest, res: Response) {
req.organization.id
);
const learnings: Record<string, LearningInterface[]> = {};
await Promise.all(
presentations.map(async (v) => {
if (v.experimentIds) {
// get the experiments to show?
//v.experiments = await getExperimentsByIds(v.experimentIds);
// get the learnings?
learnings[v.id] = await getLearningsByExperimentIds(v.experimentIds);
}
})
);
res.status(200).json({
status: 200,
presentations,
learnings,
});
}
@@ -65,26 +49,26 @@ export async function getPresentation(req: AuthRequest, res: Response) {
// get the experiments to present in this presentations:
let expIds: string[] = [];
if (pres.experimentIds) {
expIds = pres.experimentIds;
} else {
// use some other way to find the experiments... perhaps by search query options.
//TODO
if (pres.slides) {
expIds = pres.slides
.filter((o) => o.type === "experiment")
.map((o) => o.id);
}
const experiments = await getExperimentsByIds(pres.experimentIds);
// was trying to push the experiments into the presentation model,
// but that wouldn't work for some reason
const experiments = await getExperimentsByIds(expIds);
const withSnapshots: {
experiment: ExperimentInterface;
snapshot: ExperimentSnapshotInterface;
}[] = [];
const promises = experiments.map(async (experiment, i) => {
const snapshot = await getLatestSnapshot(
experiment.id,
experiment.phases.length - 1
);
// get best phase to show:
let phase = experiment.phases.length - 1;
experiment.phases.forEach((p, j) => {
if (p.phase === "main") phase = j;
});
const snapshot = await getLatestSnapshot(experiment.id, phase);
withSnapshots[i] = {
experiment,
snapshot,
@@ -93,12 +77,51 @@ export async function getPresentation(req: AuthRequest, res: Response) {
await Promise.all(promises);
// get the learnigns associated with these experiments:
const learnings = await getLearningsByExperimentIds(expIds);
res.status(200).json({
status: 200,
presentation: pres,
learnings,
experiments: withSnapshots,
});
}
export async function getPresentationPreview(req: AuthRequest, res: Response) {
const { expIds } = req.query as { expIds: string };
const expIdsArr = expIds.split(",");
if (expIdsArr.length === 0) {
res.status(403).json({
status: 404,
message: "No experiments passed",
});
return;
}
const experiments = await getExperimentsByIds(expIdsArr);
const withSnapshots: {
experiment: ExperimentInterface;
snapshot: ExperimentSnapshotInterface;
}[] = [];
const promises = experiments.map(async (experiment, i) => {
// only show experiments that you have permission to view
if (await userHasAccess(req, experiment.organization)) {
// get best phase to show:
let phase = experiment.phases.length - 1;
experiment.phases.forEach((p, j) => {
if (p.phase === "main") phase = j;
});
const snapshot = await getLatestSnapshot(experiment.id, phase);
withSnapshots[i] = {
experiment,
snapshot,
};
}
});
await Promise.all(promises);
res.status(200).json({
status: 200,
experiments: withSnapshots,
});
}
@@ -195,9 +218,11 @@ export async function updatePresentation(
if (data["title"] !== p["title"]) p.set("title", data["title"]);
if (data["description"] !== p["description"])
p.set("description", data["description"]);
p.set("experimentIds", data["experimentIds"]);
p.set("slides", data["slides"]);
p.set("options", data["options"]);
p.set("dateUpdated", new Date());
p.set("theme", data["theme"]);
p.set("customTheme", data["customTheme"]);
await p.save();

View File

@@ -7,8 +7,38 @@ const presentationSchema = new mongoose.Schema({
organization: String,
title: String,
description: String,
options: {},
experimentIds: [String],
options: {
showScreenShots: Boolean,
showGraphs: Boolean,
showInsights: Boolean,
graphType: String,
hideMetric: [String],
hideRisk: Boolean,
},
slides: [
{
_id: false,
type: { type: String },
id: String,
options: {
showScreenShots: Boolean,
showGraphs: Boolean,
showInsights: Boolean,
graphType: String,
hideMetric: [String],
hideRisk: Boolean,
},
},
],
theme: String,
customTheme: {
backgroundColor: String,
textColor: String,
headingFont: String,
bodyFont: String,
},
sharable: Boolean,
voting: Boolean,
dateCreated: Date,
dateUpdated: Date,
});

View File

@@ -1,6 +1,9 @@
import { PresentationModel } from "../models/PresentationModel";
import uniqid from "uniqid";
import { PresentationInterface } from "../../types/presentation";
import {
PresentationSlide,
PresentationInterface,
} from "../../types/presentation";
//import {query} from "../config/postgres";
export function getPresentationsByOrganization(organization: string) {
@@ -17,31 +20,42 @@ export function getPresentationById(id: string) {
export async function removeExperimentFromPresentations(experiment: string) {
const presentations = await PresentationModel.find({
experimentIds: experiment,
"slides.id": experiment,
});
await Promise.all(
presentations.map(async (presentation) => {
presentation.experimentIds = presentation.experimentIds.filter(
(id) => id !== experiment
presentation.slides = presentation.slides.filter(
(obj) => obj.id !== experiment || obj.type !== "experiment"
);
presentation.markModified("experimentIds");
presentation.markModified("slides");
await presentation.save();
})
);
}
export async function createPresentation(data: Partial<PresentationInterface>) {
return PresentationModel.create({
// Default values that can be overridden
// The data object passed in
...data,
// Values that cannot be overridden
const exps: PresentationSlide[] = [...data.slides];
const pres: PresentationInterface = {
slides: exps,
title: data?.title || "",
description: data?.description || "",
userId: data.userId,
organization: data.organization,
voting: data?.voting || true,
theme: data?.theme || "",
id: uniqid("pres_"),
dateCreated: new Date(),
dateUpdated: new Date(),
});
};
if (data?.options) {
pres.options = data.options;
}
if (data?.customTheme) {
pres.customTheme = data.customTheme;
}
return PresentationModel.create(pres);
}
export function deletePresentationById(id: string) {

View File

@@ -1,13 +1,39 @@
export type PresentationOptions = Record<string, unknown>;
//export type ShareType = "presentation" | "pdf" | "page" | "slack";
export type GraphTypes = "pill" | "violin";
export interface PresentationOptions {
showScreenShots: boolean;
showGraphs: boolean;
showInsights: boolean;
graphType: GraphTypes;
hideMetric: string[];
hideRisk: boolean;
}
export interface PresentationSlide {
id: string;
type: "experiment";
options?: PresentationOptions;
}
export interface PresentationInterface {
id: string;
userId: string;
organization: string;
title: string;
description: string;
title?: string;
description?: string;
theme?: string;
customTheme?: {
backgroundColor: string;
textColor: string;
headingFont: string;
bodyFont: string;
};
sharable?: boolean;
voting?: boolean;
options?: PresentationOptions;
experimentIds?: string[];
slides: PresentationSlide[];
dateCreated: Date;
dateUpdated: Date;
}

View File

@@ -1,9 +1,11 @@
import { FC, useState, useRef, useEffect } from "react";
const CopyToClipboard: FC<{ text: string; label?: string }> = ({
text,
label,
}) => {
const CopyToClipboard: FC<{
text: string;
label?: string;
action?: string;
className?: string;
}> = ({ text, label, action = "Copy to Clipboard", className }) => {
const [supported, setSupported] = useState(false);
const [success, setSuccess] = useState(false);
const ref = useRef(null);
@@ -28,7 +30,7 @@ const CopyToClipboard: FC<{ text: string; label?: string }> = ({
}, [success]);
return (
<div className="input-group">
<div className={`input-group ${className}`}>
<span className="mr-2" style={{ alignSelf: "center" }}>
{label}
</span>
@@ -69,7 +71,7 @@ const CopyToClipboard: FC<{ text: string; label?: string }> = ({
setSuccess(true);
}}
>
Copy to Clipboard
{action}
</button>
</>
)}

View File

@@ -1,56 +0,0 @@
.modalform {
width: 100%;
}
.modalopen {
display: block !important;
}
.modalhide {
display: none;
}
.multistep {
text-align: center;
margin-bottom: 30px;
overflow: hidden;
color: lightgrey;
.active {
color: #000000;
}
li {
list-style-type: none;
font-size: 12px;
width: 25%;
float: left;
position: relative;
}
li:before {
width: 30px;
height: 30px;
line-height: 45px;
display: block;
font-size: 18px;
color: #ffffff;
background: lightgray;
border-radius: 50%;
margin: 0 auto 10px auto;
padding: 2px;
content: "";
}
li:after {
content: "";
width: 100%;
height: 2px;
background: lightgray;
position: absolute;
left: 0;
top: 15px;
}
li.active:before,
li.active:after {
background: skyblue;
}
}

View File

@@ -1,150 +0,0 @@
//import Link from "next/link";
import styles from "./EditPresentation.module.scss";
import { useState, useEffect } from "react";
import clsx from "clsx";
import PresentationInfo from "./PresentationInfo";
import Finished from "./Finished";
import { useAuth } from "../../services/auth";
import { PresentationInterface } from "back-end/types/presentation";
const EditPresentation = ({
modalState,
setModalState,
presentation,
onSuccess,
}: {
modalState: boolean;
setModalState: (state: boolean) => void;
presentation: PresentationInterface;
onSuccess: () => void;
}): React.ReactElement => {
// handlers for the step forms:
const [presentationData, setPresentationData] = useState(presentation);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPresentationData(presentation);
}, [presentation]);
// since this is a multi step form, need to keep track of which step we're on
const [step, setStep] = useState<"form" | "done">("form");
const { apiCall } = useAuth();
const submitForm = async (e) => {
e.preventDefault();
// @todo some validation needed
if (loading) return;
setLoading(true);
setError(null);
const l = { ...presentationData };
console.log("senging ", l);
try {
const res = await apiCall<{ status: number; message?: string }>(
`/presentation/${presentation.id}`,
{
method: "POST",
body: JSON.stringify(l),
}
);
if (res.status === 200) {
setStep("done");
setLoading(false);
onSuccess();
} else {
console.error(res);
setError(
res.message ||
"There was an error submitting the form. Please try again."
);
setLoading(false);
}
} catch (e) {
console.error(e);
setError(e.message);
setLoading(false);
}
};
const loadNextButton = () => {
if (step === "done") {
return (
<button onClick={closeModal} className="btn btn-primary">
Done
</button>
);
} else {
// submit button:
return (
<button onClick={submitForm} className="btn btn-primary">
Submit
</button>
);
}
};
const closeModal = () => {
setModalState(false);
};
if (!presentationData) return <></>;
return (
<>
<div
className={clsx(
styles.modalbackground,
"modal-backdrop fade",
{ show: modalState },
{ [styles.modalhide]: !modalState }
)}
onClick={closeModal}
></div>
<div
className={clsx(
styles.modalwrap,
"modal fade bd-example-modal-lg new-presentations",
{ [styles.modalopen]: modalState },
{ show: modalState }
)}
id="exampleModal"
role="dialog"
aria-labelledby="exampleModalLabel"
aria-hidden="true"
>
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Update Presentation</h5>
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
onClick={closeModal}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<form className={styles.modalform}>
<PresentationInfo
showForm={step === "form"}
handleChange={setPresentationData}
presentationData={presentationData}
/>
<Finished showForm={step === "done"} />
</form>
{error && <div className="text-danger">{error}</div>}
</div>
<div className="modal-footer">{loadNextButton()}</div>
</div>
</div>
</div>
</>
);
};
export default EditPresentation;

View File

@@ -1,18 +0,0 @@
import React from "react";
export default function Finished({
showForm,
}: {
showForm: boolean;
}): React.ReactElement {
if (!showForm) return <></>;
return (
<>
<div>
<h5>Presentation successfully updated</h5>
<p></p>
</div>
</>
);
}

View File

@@ -1,149 +0,0 @@
import React from "react";
import useApi from "../../hooks/useApi";
import { ExperimentInterfaceStringDates } from "back-end/types/experiment";
import { PresentationInterface } from "back-end/types/presentation";
import StatusIndicator from "../Experiment/StatusIndicator";
export default function PresentationInfo({
showForm,
handleChange,
presentationData,
}: {
showForm: boolean;
handleChange: (p: PresentationInterface) => void;
presentationData: PresentationInterface;
}): React.ReactElement {
const { data } = useApi<{
experiments: ExperimentInterfaceStringDates[];
}>("/experiments");
if (!showForm) return <></>;
if (!data) return <></>;
// get the list of experiments in the right shape:
const existingExperiments = [];
if (data && data.experiments.length) {
for (let i = 0; i < data.experiments.length; i++) {
//if(ExperimentInfo)
existingExperiments.push({
id: data.experiments[i].id,
name: data.experiments[i].name,
});
}
}
const presentationDataChange = (e) => {
const { name, value } = e.target;
let newVal = value;
if (name === "tags") {
const t = value.split(",");
for (let i = 0; i < t.length; i++) {
if (i === t.length - 1) {
// allow for spaces in tag names
t[i] = t[i].trimStart().replace(/\s+$/, " ");
} else {
t[i] = t[i].trim();
}
}
newVal = t;
}
console.log("data was", presentationData);
const tmp = { ...presentationData, [name]: newVal };
console.log("now", tmp);
// pass back to parent
handleChange(tmp);
};
console.log(presentationData);
const selectedExperiments = new Map();
presentationData.experimentIds.map((id: string, ind: number) => {
selectedExperiments.set(id, ind + 1);
});
const setSelectedExperiments = (exp: ExperimentInterfaceStringDates) => {
if (selectedExperiments.has(exp.id)) {
selectedExperiments.delete(exp.id);
} else {
selectedExperiments.set(exp.id, selectedExperiments.size);
}
const tmp = {
...presentationData,
experimentIds: Array.from(selectedExperiments.keys()),
};
handleChange(tmp);
};
return (
<>
<div className="form-group">
<label>Presentation Title</label>
<input
type="text"
name="title"
className="form-control"
onChange={presentationDataChange}
value={presentationData.title}
placeholder=""
/>
</div>
<div className="form-group">
<label>More Details (optional)</label>
<textarea
name="description"
className="form-control"
value={presentationData.description}
onChange={presentationDataChange}
></textarea>
<small id="emailHelp" className="form-text text-muted">
(for your notes, not used in the presentation)
</small>
</div>
<div className="form-group">
<label>Select Experiments to Present</label>
<div className="modal-dialog-scrollable flex-wrap scrollarea">
{data.experiments.map((exp, i) => (
<div
className={`card mb-4 selectalble ${
selectedExperiments.has(exp.id) ? "selected" : ""
}`}
key={i}
onClick={() => {
setSelectedExperiments(exp);
}}
>
<div className="card-body">
<span className="selected-number">
{selectedExperiments.has(exp.id)
? selectedExperiments.get(exp.id)
: ""}
</span>
<h5 className="card-title">{exp.name}</h5>
<p className="card-text">{exp.hypothesis}</p>
<div className="metainfo">
<div>
status:{" "}
<StatusIndicator
status={exp.status}
archived={exp.archived}
/>
</div>
<div className="tags text-muted">
<span className="mr-2">Tags:</span>
{exp.tags &&
Object.values(exp.tags).map((col) => (
<span
className="tag badge badge-secondary mr-2"
key={col}
>
{col}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -455,7 +455,7 @@ const CompactResults: FC<{
);
}
return (
<>
<Fragment key={i}>
<td
className={clsx("value align-middle", {
variation: i > 0,
@@ -526,7 +526,7 @@ const CompactResults: FC<{
</div>
</td>
)}
</>
</Fragment>
);
})}
</tr>

View File

@@ -42,13 +42,13 @@ const navlinks: SidebarLinkProps[] = [
icon: "learnings.svg",
path: /^insight/,
},
*/
{
name: "Presentations",
href: "/share",
href: "/presentations",
icon: "present.svg",
path: /^share/,
path: /^presentations/,
},
*/
{
name: "Definitions",
href: "/metrics",
@@ -169,8 +169,8 @@ const Layout = (): React.ReactElement => {
// hacky:
const router = useRouter();
const path = router.route.substr(1);
if (path.match(/^present/)) {
// don't show the nav for presentations
if (path.match(/^present\//)) {
return null;
}

View File

@@ -9,6 +9,8 @@ type Props = {
cta?: string;
closeCta?: string;
size?: "md" | "lg" | "max";
navStyle?: "pills" | "underlined" | "tabs";
navFill?: boolean;
inline?: boolean;
close: () => void;
submit: () => Promise<void>;
@@ -18,7 +20,16 @@ type Props = {
};
const PagedModal: FC<Props> = (props) => {
const { step, setStep, children, submit, cta, ...passThrough } = props;
const {
step,
setStep,
children,
submit,
navStyle,
navFill,
cta,
...passThrough
} = props;
const [error, setError] = useState("");
@@ -56,6 +67,11 @@ const PagedModal: FC<Props> = (props) => {
}
}
const navStyleClass = navStyle ? "nav-" + navStyle : "nav-pills";
const navFillClass =
typeof navFill === "undefined" ? "nav-fill" : navFill ? "nav-fill" : "";
return (
<Modal
open={true}
@@ -73,7 +89,9 @@ const PagedModal: FC<Props> = (props) => {
autoCloseOnSubmit={false}
cta={!nextStep ? cta : "Next"}
>
<nav className="nav nav-pills nav-fill mb-4 justify-content-start">
<nav
className={`nav mb-4 justify-content-start ${navStyleClass} ${navFillClass}`}
>
{steps.map(({ display, enabled }, i) => (
<a
key={i}

View File

@@ -1,18 +0,0 @@
import React from "react";
export default function Finished({
showForm,
}: {
showForm: boolean;
}): React.ReactElement {
if (!showForm) return <></>;
return (
<>
<div>
<h5>New presentation successfully saved</h5>
<p></p>
</div>
</>
);
}

View File

@@ -1,56 +0,0 @@
.modalform {
width: 100%;
}
.modalopen {
display: block !important;
}
.modalhide {
display: none;
}
.multistep {
text-align: center;
margin-bottom: 30px;
overflow: hidden;
color: lightgrey;
.active {
color: #000000;
}
li {
list-style-type: none;
font-size: 12px;
width: 25%;
float: left;
position: relative;
}
li:before {
width: 30px;
height: 30px;
line-height: 45px;
display: block;
font-size: 18px;
color: #ffffff;
background: lightgray;
border-radius: 50%;
margin: 0 auto 10px auto;
padding: 2px;
content: "";
}
li:after {
content: "";
width: 100%;
height: 2px;
background: lightgray;
position: absolute;
left: 0;
top: 15px;
}
li.active:before,
li.active:after {
background: skyblue;
}
}

View File

@@ -1,154 +0,0 @@
//import Link from "next/link";
import styles from "./NewPresentation.module.scss";
import { useState } from "react";
import clsx from "clsx";
import PresentationInfo from "./PresentationInfo";
import Finished from "./Finished";
import { useAuth } from "../../services/auth";
import { PresentationInterface } from "back-end/types/presentation";
const NewLearning = ({
modalState,
setModalState,
refreshList,
}: {
modalState: boolean;
setModalState: (state: boolean) => void;
refreshList: () => void;
}): React.ReactElement => {
// handlers for the step forms:
const [presentationData, setPresentationData] = useState<
Partial<PresentationInterface>
>({
title: "A/B Tests Review",
description: "",
experimentIds: [],
options: {},
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// since this is a multi step form, need to keep track of which step we're on
const [step, setStep] = useState<"form" | "done">("form");
const { apiCall } = useAuth();
const submitForm = async (e) => {
e.preventDefault();
// @todo some validation needed
if (loading) return;
setLoading(true);
setError(null);
const l = { ...presentationData };
console.log("senging ", l);
try {
const res = await apiCall<{ status: number; message?: string }>(
"/presentation",
{
method: "POST",
body: JSON.stringify(l),
}
);
if (res.status === 200) {
onSuccess();
refreshList();
setLoading(false);
} else {
console.error(res);
setError(
res.message ||
"There was an error submitting the form. Please try again."
);
setLoading(false);
}
} catch (e) {
console.error(e);
setError(e.message);
setLoading(false);
}
};
const onSuccess = () => {
//setpresentationData(res.learning);
setStep("done");
};
const loadNextButton = () => {
if (step === "done") {
return (
<button onClick={closeModal} className="btn btn-primary">
Done
</button>
);
} else {
// submit button:
return (
<button onClick={submitForm} className="btn btn-primary">
Create
</button>
);
}
};
const closeModal = () => {
setModalState(false);
};
return (
<>
<div
className={clsx(
styles.modalbackground,
"modal-backdrop fade",
{ show: modalState },
{ [styles.modalhide]: !modalState }
)}
onClick={closeModal}
></div>
<div
className={clsx(
styles.modalwrap,
"modal fade bd-example-modal-lg new-presentations",
{ [styles.modalopen]: modalState },
{ show: modalState }
)}
id="exampleModal"
role="dialog"
aria-labelledby="exampleModalLabel"
aria-hidden="true"
>
<div className="modal-dialog modal-lg" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Add New Presentation</h5>
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
onClick={closeModal}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<form className={styles.modalform}>
<PresentationInfo
showForm={step === "form"}
handleChange={setPresentationData}
presentationData={presentationData}
/>
<Finished showForm={step === "done"} />
</form>
{error && <div className="text-danger">{error}</div>}
</div>
<div className="modal-footer">{loadNextButton()}</div>
</div>
</div>
</div>
</>
);
};
export default NewLearning;

View File

@@ -1,149 +0,0 @@
import React from "react";
import useApi from "../../hooks/useApi";
import { ExperimentInterfaceStringDates } from "back-end/types/experiment";
import { PresentationInterface } from "back-end/types/presentation";
import StatusIndicator from "../Experiment/StatusIndicator";
export default function PresentationInfo({
showForm,
handleChange,
presentationData,
}: {
showForm: boolean;
handleChange: (p: Partial<PresentationInterface>) => void;
presentationData: Partial<PresentationInterface>;
}): React.ReactElement {
const { data } = useApi<{
experiments: ExperimentInterfaceStringDates[];
}>("/experiments");
if (!showForm) return <></>;
if (!data) return <></>;
// get the list of experiments in the right shape:
const existingExperiments = [];
if (data && data.experiments.length) {
for (let i = 0; i < data.experiments.length; i++) {
//if(ExperimentInfo)
existingExperiments.push({
id: data.experiments[i].id,
name: data.experiments[i].name,
});
}
}
const presentationDataChange = (e) => {
const { name, value } = e.target;
let newVal = value;
if (name === "tags") {
const t = value.split(",");
for (let i = 0; i < t.length; i++) {
if (i === t.length - 1) {
// allow for spaces in tag names
t[i] = t[i].trimStart().replace(/\s+$/, " ");
} else {
t[i] = t[i].trim();
}
}
newVal = t;
}
console.log("data was", presentationData);
const tmp = { ...presentationData, [name]: newVal };
console.log("now", tmp);
// pass back to parent
handleChange(tmp);
};
const selectedExperiments = new Map();
presentationData.experimentIds.map((id: string, ind: number) => {
selectedExperiments.set(id, ind + 1);
});
const setSelectedExperiments = (exp: ExperimentInterfaceStringDates) => {
if (selectedExperiments.has(exp.id)) {
selectedExperiments.delete(exp.id);
} else {
selectedExperiments.set(exp.id, selectedExperiments.size);
}
const tmp = {
...presentationData,
experimentIds: Array.from(selectedExperiments.keys()),
};
handleChange(tmp);
};
return (
<>
<div className="form-group">
<label>Presentation Title</label>
<input
type="text"
name="title"
className="form-control"
onChange={presentationDataChange}
value={presentationData.title}
placeholder=""
/>
</div>
<div className="form-group">
<label>More Details (optional)</label>
<textarea
name="description"
className="form-control"
value={presentationData.description}
onChange={presentationDataChange}
></textarea>
<small id="emailHelp" className="form-text text-muted">
(for your notes, not used in the presentation)
</small>
</div>
<div className="form-group">
<label>Select Experiments to Present</label>
<div className="modal-dialog-scrollable flex-wrap scrollarea">
{data.experiments.map((exp, i) => (
<div
className={`card mb-4 selectalble ${
selectedExperiments.has(exp.id) ? "selected" : ""
}`}
key={i}
onClick={() => {
setSelectedExperiments(exp);
}}
>
<div className="card-body">
<span className="selected-number">
{selectedExperiments.has(exp.id)
? selectedExperiments.get(exp.id)
: ""}
</span>
<h5 className="card-title">{exp.name}</h5>
<p className="card-text">{exp.hypothesis}</p>
<div className="metainfo">
<div>
status:{" "}
<StatusIndicator
status={exp.status}
archived={exp.archived}
/>
</div>
<div className="tags text-muted">
<span className="mr-2">Tags:</span>
{exp.tags &&
Object.values(exp.tags).map((col) => (
<span
className="tag badge badge-secondary mr-2"
key={col}
>
{col}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -1,189 +0,0 @@
import React, { ReactElement } from "react";
//import styles from "./Presentation.module.scss";
import {
Deck,
Slide,
Heading,
FlexBox,
Box,
Progress,
FullScreen,
} from "spectacle";
import { PresentationInterface } from "back-end/types/presentation";
import {
ExperimentInterfaceStringDates,
Variation,
} from "back-end/types/experiment";
import { date } from "../services/dates";
import MetricResults from "./Experiment/MetricResults";
import { ExperimentSnapshotInterface } from "back-end/types/experiment-snapshot";
import { LearningInterface } from "back-end/types/insight";
type props = {
presentation: PresentationInterface;
experiments: {
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
}[];
learnings: LearningInterface[];
};
const Presentation = ({
presentation,
experiments,
learnings,
}: props): ReactElement => {
// make sure experiments are in the right order - we know the order is
// right in the presentation object. This could be done in the API
const em = new Map<
string,
{
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
}
>();
experiments.forEach((e) => {
em.set(e.experiment.id, e);
});
// get the learnings indexed by the experiment id
const lm = new Map();
learnings.forEach((l) => {
l.evidence.forEach((obj) => {
if (lm.has(obj.experimentId)) {
const tmp = lm.get(obj.experimentId);
tmp.push(l);
lm.set(obj.experimentId, tmp);
} else {
lm.set(obj.experimentId, [l]);
}
});
});
const expSlides = [];
presentation.experimentIds.forEach((eid) => {
// get the results in the right shape:
const e = em.get(eid);
expSlides.push(
<Slide key={expSlides.length}>
<div className="container-fluid">
<Heading>{e.experiment.name}</Heading>
<h4
className="text-center mb-4 p-2 border"
style={{ marginTop: -30 }}
>
{e.experiment.hypothesis}
</h4>
<div className="row variations">
{e.experiment.variations.map((v: Variation, j: number) => (
<div
className={`col col-${
12 / e.experiment.variations.length
} presentationcol`}
key={`v-${j}`}
>
<h4>{v.name}</h4>
<img
className="expimage"
src={v.screenshots[0] && v.screenshots[0].path}
/>
</div>
))}
</div>
</div>
</Slide>
);
if (e.snapshot) {
const variationNames = e.experiment.variations.map((v) => v.name);
const numMetrics = e.experiment.metrics.length;
e.experiment.metrics.forEach((metric, i) => {
expSlides.push(
<Slide key={expSlides.length}>
<Heading>Results</Heading>
{numMetrics > 1 && (
<p>
Metric {i + 1} of {numMetrics}
</p>
)}
<div
style={{
overflowY: "auto",
background: "#fff",
maxHeight: "100%",
padding: "0 20px",
color: "#444",
fontSize: "80%",
}}
>
<MetricResults
metric={metric}
variationNames={variationNames}
snapshot={e.snapshot}
/>
</div>
</Slide>
);
});
}
// if we have a learning from this experiment, add a learning slide
if (lm.has(eid)) {
const learnings: LearningInterface[] = lm.get(eid);
expSlides.push(
<Slide key={expSlides.length}>
<Heading>Insight</Heading>
{learnings.map((learning: LearningInterface) => (
<h4 key={`${eid}${learning.id}`} className="mb-5 text-center">
{learning.text}
</h4>
))}
</Slide>
);
}
});
const gbTheme = {
colors: {
primary: "#fff", // non heading text
secondary: "#fff", // heading text
tertiary: "#2c9ad1", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
};
const template = () => (
<FlexBox
justifyContent="space-between"
position="absolute"
bottom={0}
width={1}
>
<Box padding="0 1em">
<FullScreen color="#fff" size={30} />
</Box>
<Box padding="1em">
<Progress color="#fff" size={10} />
</Box>
</FlexBox>
);
return (
<>
<Deck theme={gbTheme} template={template}>
<Slide>
<FlexBox height="100%" className="flexwrap">
<Heading>
{presentation.title ? presentation.title : "A/B Tests Review"}
<h4 className="subtitle">{date(presentation.dateCreated)}</h4>
</Heading>
</FlexBox>
</Slide>
{expSlides}
</Deck>
</>
);
};
export default Presentation;

View File

@@ -0,0 +1,319 @@
import React, { Fragment, ReactElement } from "react";
import {
Deck,
Slide,
Heading,
FlexBox,
Box,
Progress,
FullScreen,
Appear,
Text,
} from "spectacle";
import { PresentationInterface } from "back-end/types/presentation";
import {
ExperimentInterfaceStringDates,
Variation,
} from "back-end/types/experiment";
import { ExperimentSnapshotInterface } from "back-end/types/experiment-snapshot";
import CompactResults from "../Experiment/CompactResults";
import { presentationThemes, defaultTheme } from "./ShareModal";
import clsx from "clsx";
import Markdown from "../Markdown/Markdown";
type props = {
presentation?: PresentationInterface;
theme?: string;
title?: string;
desc?: string;
customTheme?: {
backgroundColor: string;
textColor: string;
headingFont?: string;
bodyFont?: string;
};
experiments: {
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
}[];
preview?: boolean;
};
const Presentation = ({
presentation,
experiments,
theme = defaultTheme,
title,
desc,
customTheme,
preview = false,
}: props): ReactElement => {
// make sure experiments are in the right order - we know the order is
// right in the presentation object. This could be done in the API
const em = new Map<
string,
{
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
}
>();
experiments.forEach((e) => {
em.set(e.experiment.id, e);
});
const expSlides = [];
// use the list of experiments from the presentation or, if missing the
// presentation (in the case of preview), from the list of experiments
// passed in.
(
presentation?.slides.map((o) => o.id) ||
experiments.map((e) => {
return e.experiment.id;
})
).forEach((eid) => {
// get the results in the right shape:
const e = em.get(eid);
// get the info on which variation to mark as winner/loser
const variationExtra = [];
let sideExtra = <></>;
const variationsPlural =
e.experiment.variations.length > 2 ? "variations" : "variation";
e.experiment.variations.forEach((v, i) => {
variationExtra[i] = <Fragment key={`f-${i}`}></Fragment>;
});
let resultsText = "";
if (
e.experiment?.status === "running" ||
e.experiment?.status === "draft"
) {
resultsText = "This experiment is still in progress";
} else {
// stopped:
if (e.experiment?.results) {
if (e.experiment.results === "won") {
// if this is a two sided test, mark the winner:
variationExtra[e.experiment.winner] = (
<Appear>
<Text className="result variation-result result-winner text-center p-2 m-0">
Winner!
</Text>
</Appear>
);
resultsText =
e.experiment.variations[e.experiment.winner]?.name +
" beat the control and won";
} else if (e.experiment.results === "lost") {
resultsText = `The ${variationsPlural} beat the control and won`;
if (e.experiment.variations.length === 2) {
variationExtra[1] = (
<Appear>
<Text className="result variation-result result-lost text-center p-2 m-0">
Lost!
</Text>
</Appear>
);
} else {
variationExtra[0] = (
<Appear>
<Text className="result variation-result result-winner text-center p-2 m-0">
Winner!
</Text>
</Appear>
);
}
} else if (e.experiment.results === "dnf") {
sideExtra = (
<div className="result result-dnf text-center">
(Did not finish)
</div>
);
resultsText = `The experiment did not finish`;
} else if (e.experiment.results === "inconclusive") {
sideExtra = (
<Appear>
<Text className="result result-inconclusive text-center m-0 p-3">
Inconclusive
</Text>
</Appear>
);
resultsText = `The results were inconclusive`;
}
}
}
expSlides.push(
<Slide key={expSlides.length}>
<div className="container-fluid">
<Heading className="m-0 pb-0">{e.experiment.name}</Heading>
<Text className="text-center m-0 mb-4 p-2" fontSize={21}>
{e.experiment.hypothesis}
</Text>
<div className="row variations">
{e.experiment.variations.map((v: Variation, j: number) => (
<Text
fontSize={20}
className={`col m-0 p-0 col-${
12 / e.experiment.variations.length
} presentationcol text-center`}
key={`v-${j}`}
>
<h4>{v.name}</h4>
<img
className="expimage border"
src={v.screenshots[0] && v.screenshots[0].path}
/>
{variationExtra[j]}
</Text>
))}
</div>
{sideExtra}
</div>
</Slide>
);
if (e.snapshot) {
// const variationNames = e.experiment.variations.map((v) => v.name);
// const numMetrics = e.experiment.metrics.length;
const result = e.experiment.results;
if (result)
expSlides.push(
<Slide key={`s-${expSlides.length}`}>
<Heading className="m-0 p-0">Results</Heading>
<div
className={clsx("alert", {
"alert-success": result === "won",
"alert-danger": result === "lost",
"alert-info": !result || result === "inconclusive",
"alert-warning": result === "dnf",
})}
>
<strong>{resultsText}</strong>
{e.experiment.analysis && (
<div className="card text-dark mt-2">
<div className="card-body">
<Markdown className="card-text">
{e.experiment.analysis}
</Markdown>
</div>
</div>
)}
</div>
<div
style={{
overflowY: "auto",
background: "#fff",
maxHeight: "100%",
padding: "0 0",
color: "#444",
fontSize: "95%",
}}
>
<CompactResults snapshot={e.snapshot} experiment={e.experiment} />
</div>
</Slide>
);
}
});
const template = () => (
<FlexBox
justifyContent="space-between"
position="absolute"
bottom={0}
width={1}
>
<Box padding="0 1em">
<FullScreen color="#fff" size={30} />
</Box>
<Box padding="1em">
<Progress color="#fff" size={10} />
</Box>
</FlexBox>
);
const themeName = presentation?.theme ? presentation.theme : theme;
const currentTheme = presentationThemes[themeName];
if (themeName === "custom") {
if (presentation?.customTheme) {
// set in the presentation object from mongo:
currentTheme.colors.tertiary = presentation.customTheme.backgroundColor;
currentTheme.colors.primary = presentation.customTheme.textColor;
currentTheme.colors.secondary = presentation.customTheme.textColor;
if (!("fonts" in currentTheme))
currentTheme.fonts = { header: "", text: "" };
currentTheme.fonts.header = presentation.customTheme.headingFont;
currentTheme.fonts.text = presentation.customTheme.bodyFont;
} else {
// the custom theme is set by a preview:
if (!("fonts" in currentTheme))
currentTheme.fonts = { header: "", text: "" };
if (customTheme?.backgroundColor) {
currentTheme.colors.tertiary = customTheme.backgroundColor;
}
if (customTheme?.textColor) {
currentTheme.colors.primary = customTheme.textColor;
currentTheme.colors.secondary = customTheme.textColor;
}
if (customTheme?.bodyFont) {
currentTheme.fonts.text = customTheme.bodyFont;
}
if (customTheme?.headingFont) {
currentTheme.fonts.header = customTheme.headingFont;
}
}
}
if (preview) {
// we have to tweak a few things to make it work in a div
currentTheme.Backdrop = "div";
currentTheme.backdropStyle = {
backgroundColor: "#ffffff",
};
currentTheme.size = {
width: "100%",
height: "100%",
maxCodePaneHeight: 200,
};
}
return (
<div className={`presentation ${preview ? "presentation-preview" : ""}`}>
<Deck theme={currentTheme} template={template}>
<Slide>
<FlexBox height="100%" className="flexwrap">
<Heading fontSize={55}>
{presentation?.title
? presentation.title
: title
? title
: "A/B Tests Review"}
{presentation?.description ? (
<Text className="subtitle" fontSize={20}>
{presentation.description}
</Text>
) : desc ? (
<Text className="subtitle" fontSize={20}>
{desc}
</Text>
) : (
""
)}
</Heading>
</FlexBox>
</Slide>
{expSlides}
<Slide>
<FlexBox height="100%" className="flexwrap">
<Heading fontSize={55}>Thanks!</Heading>
</FlexBox>
</Slide>
</Deck>
</div>
);
};
export default Presentation;

View File

@@ -0,0 +1,75 @@
import React, { FC } from "react";
import dynamic from "next/dynamic";
import useApi from "../../hooks/useApi";
import LoadingOverlay from "../../components/LoadingOverlay";
import { ExperimentInterfaceStringDates } from "back-end/types/experiment";
import { PresentationInterface } from "back-end/types/presentation";
import useSwitchOrg from "../../services/useSwitchOrg";
//import { LearningInterface } from "back-end/types/insight";
import { ExperimentSnapshotInterface } from "back-end/types/experiment-snapshot";
const DynamicPresentation = dynamic(
() => import("../../components/Share/Presentation"),
{
ssr: false,
//loading: () => (<p>Loading...</p>) // this causes a lint error
}
);
const Preview: FC<{
expIds: string;
theme: string;
title: string;
desc: string;
backgroundColor: string;
textColor: string;
headingFont?: string;
bodyFont?: string;
}> = ({
expIds,
theme,
title,
desc,
backgroundColor,
textColor,
headingFont,
bodyFont,
}) => {
const { data: pdata, error } = useApi<{
status: number;
presentation: PresentationInterface;
experiments: {
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
}[];
}>(`/presentation/preview/?expIds=${expIds}`);
useSwitchOrg(pdata?.presentation?.organization);
if (error) {
return (
<div className="alert alert-danger">
Couldn&apos;t find the presentation. Are you sure it still exists?
</div>
);
}
if (!pdata) {
return <LoadingOverlay />;
}
return (
<DynamicPresentation
experiments={pdata.experiments}
theme={theme}
preview={true}
title={title}
desc={desc}
customTheme={{
backgroundColor: "#" + backgroundColor,
textColor: "#" + textColor,
headingFont: headingFont,
bodyFont: bodyFont,
}}
/>
);
};
export default Preview;

View File

@@ -0,0 +1,870 @@
import React, { useContext } from "react";
import PagedModal from "../Modal/PagedModal";
import Page from "../Modal/Page";
import { useState } from "react";
import { useSearch } from "../../services/search";
import { UserContext } from "../ProtectedPage";
import useForm from "../../hooks/useForm";
import { useAuth } from "../../services/auth";
import Tabs from "../Tabs/Tabs";
import Tab from "../Tabs/Tab";
import Preview from "./Preview";
import { ago, datetime } from "../../services/dates";
import { ExperimentInterfaceStringDates } from "back-end/types/experiment";
import {
PresentationInterface,
PresentationSlide,
} from "back-end/types/presentation";
import ResultsIndicator from "../Experiment/ResultsIndicator";
import {
resetServerContext,
DragDropContext,
Droppable,
Draggable,
} from "react-beautiful-dnd";
import { GrDrag } from "react-icons/gr";
import { FaCheck, FaRegTrashAlt } from "react-icons/fa";
import { FiAlertTriangle } from "react-icons/fi";
import { HexColorPicker } from "react-colorful";
import Tooltip from "../Tooltip";
import LoadingSpinner from "../LoadingSpinner";
import useApi from "../../hooks/useApi";
import { date } from "../../services/dates";
export const presentationThemes = {
lblue: {
title: "Light Blue",
show: true,
colors: {
primary: "#023047", // non heading text
secondary: "#023047", // heading text
tertiary: "#cae9ff", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
midBlue: {
title: "Blue",
show: true,
colors: {
primary: "#f1faee", // non heading text
secondary: "#f1faee", // heading text
tertiary: "#2c9ad1", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
dblue: {
title: "Dark Blue",
show: true,
colors: {
primary: "#f1faee", // non heading text
secondary: "#f1faee", // heading text
tertiary: "#1d3557", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
red: {
title: "Red",
show: true,
colors: {
primary: "#fff", // non heading text
secondary: "#fff", // heading text
tertiary: "#d90429", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
purple: {
title: "Purple",
show: true,
colors: {
primary: "#fff", // non heading text
secondary: "#fff", // heading text
tertiary: "#320a80", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
green: {
title: "Green",
show: true,
colors: {
primary: "#fff", // non heading text
secondary: "#fff", // heading text
tertiary: "#006466", // background
quaternary: "blue", // ?
quinary: "red", // ?
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
custom: {
title: "Custom",
show: true,
colors: {
primary: "#444", // non heading text
secondary: "#444", // heading text
tertiary: "#FFF", // background
},
fontSizes: {
h1: "40px",
h2: "30px",
header: "64px",
paragraph: "28px",
text: "28px",
},
},
};
export const defaultTheme = "purple";
const ShareModal = ({
modalState,
setModalState,
title = "New Presentation",
existing,
refreshList,
onSuccess,
}: {
modalState: boolean;
setModalState: (state: boolean) => void;
title?: string;
existing?: PresentationInterface;
refreshList?: () => void;
onSuccess?: () => void;
}): React.ReactElement => {
const { data, error } = useApi<{
experiments: ExperimentInterfaceStringDates[];
}>("/experiments");
//const [expStatus, setExpStatus] = useState("stopped");
const [step, setStep] = useState(0);
const [loading, setLoading] = useState<boolean>(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { getUserDisplay } = useContext(UserContext);
const [value, inputProps, manualUpdate] = useForm<
Partial<PresentationInterface>
>(
{
title: existing?.title || "A/B Test Review",
description: existing?.description || date(new Date()),
theme: existing?.theme || defaultTheme,
customTheme: existing?.customTheme || {
backgroundColor: "#3400a3",
textColor: "#ffffff",
headingFont: '"Helvetica Neue", Helvetica, Arial, sans-serif',
bodyFont: '"Helvetica Neue", Helvetica, Arial, sans-serif',
},
slides: existing?.slides || [],
sharable: existing?.sharable || true,
},
existing?.id
);
const {
list: experiments,
searchInputProps,
isFiltered,
} = useSearch(data?.experiments || [], [
"name",
"implementation",
"hypothesis",
"description",
"tags",
"trackingKey",
"status",
"id",
"owner",
"metrics",
"results",
"analysis",
]);
const { apiCall } = useAuth();
const submitForm = async () => {
if (loading) return;
setLoading(true);
setSaveError(null);
const l = { ...value };
try {
// paths for update or save new
const postURL = existing?.id
? `/presentation/${existing.id}`
: "/presentation";
await apiCall<{ status: number; message?: string }>(postURL, {
method: "POST",
body: JSON.stringify(l),
});
if (onSuccess && typeof onSuccess === "function") onSuccess();
setLoading(false);
refreshList();
} catch (e) {
console.error(e);
setSaveError(e.message);
setLoading(false);
}
};
if (!data) {
// still loading...
return null;
}
if (error) {
return (
<div className="alert alert-danger">
An error occurred: {error.message}
</div>
);
}
if (experiments.length === 0) {
return (
<div className="alert alert-danger">
You need some experiments to share first.
</div>
);
}
const byId = new Map();
const byStatus: {
stopped: ExperimentInterfaceStringDates[];
archived: ExperimentInterfaceStringDates[];
//draft: ExperimentInterfaceStringDates[];
running: ExperimentInterfaceStringDates[];
//myDrafts: ExperimentInterfaceStringDates[];
} = {
stopped: [],
archived: [],
//draft: [],
running: [],
//myDrafts: [],
};
//const defaultGraph = "pill";
// organize existing experiments by status
experiments.forEach((test) => {
if (test.archived) {
byStatus.archived.push(test);
} else if (test.status in byStatus) {
byStatus[test.status].push(test);
}
byId.set(test.id, test);
});
const selectedExperiments = new Map();
value.slides.forEach((obj: PresentationSlide) => {
selectedExperiments.set(obj.id, byId.get(obj.id));
});
const setSelectedExperiments = (exp: ExperimentInterfaceStringDates) => {
// const defaultOptions = {
// showScreenShots: true,
// showGraphs: true,
// showInsights: false,
// graphType: "violin",
// hideMetric: [],
// hideRisk: false,
// };
if (selectedExperiments.has(exp.id)) {
selectedExperiments.delete(exp.id);
} else {
selectedExperiments.set(exp.id, exp);
}
const exps = [];
// once we add options, we'll have to make this merge in previous options per exp
Array.from(selectedExperiments.keys()).forEach((e) => {
exps.push({ id: e, type: "experiment" });
});
const tmp = {
...value,
slides: exps,
};
manualUpdate(tmp);
};
const reorder = (slides, startIndex, endIndex) => {
const result = [...slides];
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onDragEnd = (result) => {
// dropped outside the list
if (!result.destination) {
return;
}
const tmp = {
...value,
slides: reorder(
value.slides,
result.source.index,
result.destination.index
),
};
manualUpdate(tmp);
};
const grid = 4;
const getItemStyle = (isDragging, draggableStyle) => ({
// some basic styles to make the items look a bit nicer
userSelect: "none",
padding: grid * 2,
margin: `0 0 ${grid}px 0`,
// change background colour if dragging
background: isDragging ? "lightgreen" : "",
// styles we need to apply on draggables
...draggableStyle,
});
resetServerContext();
const tabContents = [];
Object.entries(byStatus).forEach(([status]) => {
tabContents.push(
<Tab
key={status}
display={
status.charAt(0).toUpperCase() + status.substr(1).toLowerCase()
}
anchor={status}
count={byStatus[status].length}
>
{byStatus.stopped.length > 0 ? (
<table className="table table-hover experiment-table appbox">
<thead>
<tr>
<th></th>
<th style={{ width: "99%" }}>Experiment</th>
<th>Tags</th>
<th>Owner</th>
<th>Ended</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{byStatus[status]
.sort(
(a, b) =>
new Date(
b.phases[b.phases.length - 1]?.dateEnded
).getTime() -
new Date(a.phases[a.phases.length - 1]?.dateEnded).getTime()
)
.map((e: ExperimentInterfaceStringDates) => {
const phase = e.phases[e.phases.length - 1];
if (!phase) return null;
let hasScreenShots = true;
e.variations.forEach((v) => {
if (v.screenshots.length < 1) {
hasScreenShots = false;
}
});
return (
<tr
key={e.id}
onClick={(event) => {
event.preventDefault();
setSelectedExperiments(e);
}}
className={`cursor-pointer ${
selectedExperiments.has(e.id) ? "selected" : ""
}`}
>
<td>
<span className="h3 mb-0 checkmark">
<FaCheck />
</span>
</td>
<td>
<div className="d-flex">
<h4 className="testname h5">
{e.name}
{hasScreenShots ? (
<></>
) : (
<span className="text-warning pl-3">
<Tooltip text="This experiment is missing screen shots">
<FiAlertTriangle />
</Tooltip>
</span>
)}
</h4>
</div>
</td>
<td className="nowrap">
{Object.values(e.tags).map((col, i) => (
<span
className="tag badge badge-secondary mr-2"
key={i}
>
{col}
</span>
))}
</td>
<td className="nowrap">
{getUserDisplay(e.owner, false)}
</td>
<td className="nowrap" title={datetime(phase.dateEnded)}>
{ago(phase.dateEnded)}
</td>
<td className="nowrap">
<ResultsIndicator results={e.results} />
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="alert alert-info">
No {isFiltered ? "matching" : "stopped"} experiments
</div>
)}
</Tab>
);
// end of the byStatus loop
});
// end tab contents
let counter = 0;
const selectedList = [];
//const expOptionsList = [];
selectedExperiments.forEach((exp: ExperimentInterfaceStringDates, id) => {
selectedList.push(
<Draggable key={id} draggableId={id} index={counter++}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="shared-exp-div"
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style
)}
>
<div className="d-flex align-items-center">
<span className="drag-handle mr-2" {...provided.dragHandleProps}>
<GrDrag />
</span>
<h5 className="mb-0">{exp.name}</h5>
<div className="ml-auto">
<span
className="delete-exp cursor-pointer"
onClick={(e) => {
e.preventDefault();
setSelectedExperiments(exp);
}}
>
<FaRegTrashAlt />
</span>
</div>
</div>
</div>
)}
</Draggable>
);
// adding options for each experiment... disabled for now
// expOptionsList.push(
// <div>
// {exp.name}
// <div className="row">
// <div className="col">
// <div className="form-group form-check">
// <label className="form-check-label">
// <input
// type="checkbox"
// className="form-check-input"
// checked={value.options[id]?.showScreenShots}
// onChange={(e) => {
// const opt = { ...value.options };
// opt[id].showScreenShots = e.target.checked;
// const tmp = {
// ...value,
// options: opt,
// };
// manualUpdate(tmp);
// }}
// id="checkbox-showscreenshots"
// />
// Show screen shots (if avaliable)
// </label>
// </div>
// <div className="form-row form-inline">
// <div className="form-group">
// <label className="mr-3">Graph type</label>
// <select
// className="form-control"
// {...inputProps.options[id].graphType}
// >
// <option selected>{defaultGraph}</option>
// <option>violin</option>
// </select>
// </div>
// </div>
// </div>
// </div>
// </div>
// );
});
const presThemes = [];
for (const [key, value] of Object.entries(presentationThemes)) {
if (value.show) {
presThemes.push(
<option value={key} key={key}>
{value.title}
</option>
);
}
}
if (!modalState) {
return null;
}
const fontOptions = (
<>
<option value='"Helvetica Neue", Helvetica, Arial, sans-serif'>
Helvetica Neue
</option>
<option value="Arial">Arial</option>
<option value="Impact">Impact</option>
<option value='"Times New Roman", serif'>Times New Roman</option>
<option value="American Typewriter">American Typewriter</option>
<option value="Courier, Monospace">Courier</option>
<option value='"Comic Sans MS", "Comic Sans"'>Comic Sans</option>
<option value="Cursive">Cursive</option>
</>
);
return (
<PagedModal
header={title}
close={() => setModalState(false)}
submit={submitForm}
cta="Save"
closeCta="Cancel"
navStyle="underlined"
navFill={true}
size="max"
step={step}
setStep={setStep}
>
<Page display="Select Experiments">
<div className="row new-share">
<div className="col-sm-12 col-md-4 mb-5">
<h4>Selected experiments to share</h4>
<div className="selected-area h-100">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className=""
>
{selectedList.length ? (
selectedList.map((l) => {
return l;
})
) : (
<span className="text-muted">
Choose experiments from the list
</span>
)}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
<div className="col-sm-12 col-md-8">
<div className="form-group">
<div className="filters md-form row mb-3 align-items-center">
<div className="col">
<input
type="search"
className=" form-control"
placeholder="Search"
aria-controls="dtBasicExample"
{...searchInputProps}
/>
</div>
</div>
<Tabs
defaultTab={
byStatus.stopped.length > 0
? "Stopped"
: byStatus.running.length > 0
? "Running"
: null
}
>
{tabContents.map((con) => {
return con;
})}
</Tabs>
</div>
</div>
</div>
</Page>
{/* <Page display="Experiment options">
<div className="row new-share">
<div className="col-sm-12 col-md-4">
<h4>Selected experiments to share</h4>
<div className="selected-area h-100"></div>
</div>
<div className="col-sm-12 col-md-8">
{expOptionsList.map((con) => {
return con;
})}
</div>
</div>
</Page> */}
<Page display="Presentation Options">
<div className="row new-share">
<div className="col-sm-12 col-md-6">
<div className="form-group row">
<label
htmlFor="inputtitle"
className="col-sm-4 col-form-label text-right"
>
Title
</label>
<div className="col-sm-8">
<input
type="text"
className="form-control"
id="inputtitle"
placeholder=""
{...inputProps.title}
/>
</div>
</div>
<div className="form-group row">
<label
htmlFor="inputdesc"
className="col-sm-4 col-form-label text-right"
>
Sub-title
</label>
<div className="col-sm-8">
<input
type="text"
className="form-control"
id="inputdesc"
placeholder=""
{...inputProps.description}
/>
</div>
</div>
{/* <div className="form-group row">
<label className="form-check-label col-sm-4 col-form-label text-right">
Enable sharing
</label>
<div className="col-sm-8" style={{ verticalAlign: "middle" }}>
<input
type="checkbox"
className=""
checked={value.sharable}
onChange={(e) => {
manualUpdate({ ...value, sharable: e.target.checked });
}}
id="checkbox-voting"
/>
</div>
</div> */}
{/* <div className="form-group row">
<label className="form-check-label col-sm-4 col-form-label text-right">
Enable voting
</label>
<div className="col-sm-8" style={{ verticalAlign: "middle" }}>
<input
type="checkbox"
className=""
checked={value.voting}
onChange={(e) => {
manualUpdate({ ...value, voting: e.target.checked });
}}
id="checkbox-voting"
/>
</div>
</div>*/}
<div className="form-group row">
<label htmlFor="" className="col-sm-4 col-form-label text-right">
Presentation theme
</label>
<div className="col-sm-8">
<select className="form-control" {...inputProps.theme}>
{presThemes.map((opt) => {
return opt;
})}
</select>
</div>
</div>
{value.theme === "custom" && (
<>
<div className="form-group row">
<label className="col-sm-4 col-form-label text-right">
Heading font
</label>
<div className="col-sm-12 col-md-8">
<select
className="form-control"
value={value.customTheme?.headingFont}
onChange={(e) => {
const tmp = { ...value };
tmp.customTheme["headingFont"] = e.target.value;
manualUpdate(tmp);
}}
>
{fontOptions}
</select>
</div>
</div>
<div className="form-group row">
<label className="col-sm-4 col-form-label text-right">
Body font
</label>
<div className="col-sm-12 col-md-8">
<select
className="form-control"
value={value.customTheme?.bodyFont}
onChange={(e) => {
const tmp = { ...value };
tmp.customTheme["bodyFont"] = e.target.value;
manualUpdate(tmp);
}}
>
{fontOptions}
</select>
</div>
</div>
<div className="form-group row">
<div className="col text-center">
<label htmlFor="custombackground" className="text-center">
Background color
</label>
<HexColorPicker
onChange={(c) => {
const tmp = { ...value };
tmp.customTheme["backgroundColor"] = c;
manualUpdate(tmp);
}}
style={{ margin: "0 auto" }}
color={value.customTheme?.backgroundColor || ""}
id="custombackground"
/>
</div>
<div className="col text-center">
<label htmlFor="custombackground" className="text-center">
Text color
</label>
<HexColorPicker
onChange={(c) => {
const tmp = { ...value };
tmp.customTheme["textColor"] = c;
manualUpdate(tmp);
}}
style={{ margin: "0 auto" }}
color={value.customTheme?.textColor || ""}
id="customtextcolor"
/>
</div>
</div>
</>
)}
</div>
<div className="col-sm-12 col-md-6" style={{ minHeight: "350px" }}>
<h4>
Preview{" "}
<small className="text-muted">
(use the arrow keys to change pages)
</small>
</h4>
<div style={{ position: "absolute", left: "49%", top: "52%" }}>
<LoadingSpinner />
</div>
<div
style={{
width: "100%",
height: "100%",
maxHeight: "350px",
position: "relative",
}}
>
<Preview
expIds={value.slides
.map((o) => {
return o.id;
})
.join(",")}
title={value.title}
desc={value.description}
theme={value.theme}
backgroundColor={value.customTheme.backgroundColor.replace(
"#",
""
)}
textColor={value.customTheme.textColor.replace("#", "")}
headingFont={value.customTheme.headingFont}
bodyFont={value.customTheme.bodyFont}
/>
</div>
</div>
</div>
{saveError}
</Page>
</PagedModal>
);
};
export default ShareModal;

View File

@@ -28,7 +28,9 @@
"md5": "^2.3.0",
"next": "^10.2.0",
"react": "^17.0.1",
"react-beautiful-dnd": "^13.1.0",
"react-bootstrap-typeahead": "^5.0.0-rc.3",
"react-colorful": "^5.3.0",
"react-datepicker": "^3.3.0",
"react-dom": "^17.0.1",
"react-dropzone": "^11.3.2",
@@ -39,7 +41,7 @@
"react-textarea-autosize": "^8.3.2",
"recharts": "^1.8.5",
"rich-markdown-editor": "^11.4.0-0",
"spectacle": "^8.2.0",
"spectacle": "^8.3.0",
"swr": "^0.5.4"
},
"devDependencies": {

View File

@@ -6,10 +6,9 @@ import LoadingOverlay from "../../components/LoadingOverlay";
import { ExperimentInterfaceStringDates } from "back-end/types/experiment";
import { PresentationInterface } from "back-end/types/presentation";
import useSwitchOrg from "../../services/useSwitchOrg";
import { LearningInterface } from "back-end/types/insight";
import { ExperimentSnapshotInterface } from "back-end/types/experiment-snapshot";
const DynamicPresentation = dynamic(
() => import("../../components/Presentation"),
() => import("../../components/Share/Presentation"),
{
ssr: false,
//loading: () => (<p>Loading...</p>) // this causes a lint error
@@ -22,7 +21,6 @@ const PresentPage = (): React.ReactElement => {
const { data: pdata, error } = useApi<{
status: number;
presentation: PresentationInterface;
learnings: LearningInterface[];
experiments: {
experiment: ExperimentInterfaceStringDates;
snapshot?: ExperimentSnapshotInterface;
@@ -46,7 +44,6 @@ const PresentPage = (): React.ReactElement => {
<DynamicPresentation
presentation={pdata.presentation}
experiments={pdata.experiments}
learnings={pdata.learnings}
/>
</>
);

View File

@@ -1,18 +1,19 @@
import React from "react";
import React, { useContext } from "react";
import useApi from "../hooks/useApi";
import Link from "next/link";
import { useState } from "react";
import LoadingOverlay from "../components/LoadingOverlay";
import { LearningInterface } from "back-end/types/insight";
import { PresentationInterface } from "back-end/types/presentation";
import NewPresentation from "../components/NewPresentation/NewPresentation";
import ShareModal from "../components/Share/ShareModal";
import ConfirmModal from "../components/ConfirmModal";
import { useAuth } from "../services/auth";
import EditPresentation from "../components/EditPresentation/EditPresentation";
import { date } from "../services/dates";
import { FaPlus } from "react-icons/fa";
import Modal from "../components/Modal";
import { UserContext } from "../components/ProtectedPage";
import CopyToClipboard from "../components/CopyToClipboard";
const SharePage = (): React.ReactElement => {
const PresentationPage = (): React.ReactElement => {
const [openNewPresentationModal, setOpenNewPresentationModal] = useState(
false
);
@@ -23,20 +24,27 @@ const SharePage = (): React.ReactElement => {
const [openEditPresentationModal, setOpenEditPresentationModal] = useState(
false
);
const [sharableLinkModal, setSharableLinkModal] = useState(false);
const [sharableLink, setSharableLink] = useState("");
const [deleteConfirmModal, setDeleteConfirmModal] = useState<boolean>(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { getUserDisplay } = useContext(UserContext);
const { apiCall } = useAuth();
const { data: p, error: error, mutate } = useApi<{
presentations: PresentationInterface[];
learnings: LearningInterface[];
//learnings: LearningInterface[];
numExperiments: number;
}>("/presentations");
if (error) {
return <div className="alert alert-danger">An error occurred</div>;
return (
<div className="alert alert-danger">
An error occurred fetching the lists of shares.
</div>
);
}
if (!p) {
return <LoadingOverlay />;
@@ -63,9 +71,10 @@ const SharePage = (): React.ReactElement => {
setOpenNewPresentationModal(true);
}}
>
<FaPlus /> Add your first Presentation
<FaPlus /> Add your first presentation
</button>
<NewPresentation
<ShareModal
title="New Presentation"
modalState={openNewPresentationModal}
setModalState={setOpenNewPresentationModal}
refreshList={mutate}
@@ -98,7 +107,6 @@ const SharePage = (): React.ReactElement => {
if (res.status === 200) {
setDeleteLoading(false);
setDeleteConfirmModal(false);
mutate();
} else {
console.error(res);
@@ -128,24 +136,30 @@ const SharePage = (): React.ReactElement => {
presList.push(
<div className="card mt-2" key={`pres-exp-${i}`}>
<div className="card-body">
<div key={i} className="row">
<div className="col">
<div key={i} className="row d-flex">
<div className="col flex-grow-1">
<h4 className="mb-0">{pres.title}</h4>
<div className="subtitle text-muted text-sm">
<small>{date(pres.dateCreated)}</small>
</div>
<p className="mt-1 mb-0">{pres.description}</p>
</div>
<div className="col col-4">
Experiments: {pres.experimentIds.length}
<p className="mt-1 mb-0">
<div className="px-4">
Experiments: {pres?.slides.length || "?"}
<div className="subtitle text-muted text-sm">
<small>
<p className="mb-0">
Created by: {getUserDisplay(pres?.userId)}
</p>
<p className="mb-0">on: {date(pres.dateCreated)}</p>
</small>
</div>
{/* <p className="mt-1 mb-0">
Insights:{" "}
{p.learnings[pres.id] ? p.learnings[pres.id].length : 0}
</p>
</p> */}
</div>
<div className="col col-3">
<div className="">
<div
className="delete delete-right"
style={{ lineHeight: "36px" }}
onClick={() => {
deleteConfirm(pres.id);
}}
@@ -162,6 +176,7 @@ const SharePage = (): React.ReactElement => {
</div>
<div
className="edit edit-right"
style={{ lineHeight: "36px" }}
onClick={() => {
setSpecificPresentation(pres);
setOpenEditPresentationModal(true);
@@ -179,7 +194,7 @@ const SharePage = (): React.ReactElement => {
</div>
<Link href="/present/[pid]" as={`/present/${pres.id}`}>
<a
className="btn btn-primary btn-sm"
className="btn btn-primary mr-3"
target="_blank"
rel="noreferrer"
>
@@ -187,14 +202,36 @@ const SharePage = (): React.ReactElement => {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#fff"
height="24"
height="22"
viewBox="0 0 24 24"
width="24"
width="22"
>
<path d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18c.62-.39.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z" />
</svg>
</a>
</Link>
<Link
href={`/present/[pid]?exportMode=true&printMode=true`}
as={`/present/${pres.id}?exportMode=true&printMode=true`}
>
<a
className="btn btn-outline-primary mr-3"
target="_blank"
rel="noreferrer"
>
Print view
</a>
</Link>
<a
className="btn btn-outline-primary mr-3"
onClick={(e) => {
e.preventDefault();
setSharableLink(`/present/${pres.id}`);
setSharableLinkModal(true);
}}
>
Get link
</a>
</div>
</div>
</div>
@@ -218,16 +255,18 @@ const SharePage = (): React.ReactElement => {
</button>
</div>
</div>
<NewPresentation
<ShareModal
title="New Presentation"
modalState={openNewPresentationModal}
setModalState={setOpenNewPresentationModal}
refreshList={mutate}
/>
<EditPresentation
<ShareModal
title="Edit Presentation"
modalState={openEditPresentationModal}
setModalState={setOpenEditPresentationModal}
presentation={specificPresentation}
onSuccess={mutate}
existing={specificPresentation}
refreshList={mutate}
/>
<ConfirmModal
title="Are you sure you want to delete this presentation?"
@@ -240,8 +279,36 @@ const SharePage = (): React.ReactElement => {
confirmDelete();
}}
/>
{sharableLinkModal && (
<Modal
open={true}
header={"Sharable link"}
close={() => setSharableLinkModal(false)}
size="md"
>
<div className="text-center">
<div className="text-center mb-2">
<CopyToClipboard
text={`${window.location.origin}${sharableLink}?exportMode=true`}
label="Non-slide version"
className="justify-content-center"
/>
</div>
<div className="text-center mb-2">
<CopyToClipboard
text={`${window.location.origin}${sharableLink}`}
label="Full presentation"
className="justify-content-center"
/>
</div>
<small className="text-muted text-center">
(Users will need an account on your organizaiton to view)
</small>
</div>
</Modal>
)}
{deleteError}
</>
);
};
export default SharePage;
export default PresentationPage;

View File

@@ -163,6 +163,10 @@ pre {
opacity: 1;
}
}
.nav-underlined .nav-link.active, .nav-underlined .show > .nav-link {
border-bottom: 2px solid #029dd1;
font-weight: bold;
}
.welcome {
z-index: 1000;
position: relative;
@@ -1030,6 +1034,44 @@ li.reports a span img {
}
}
.new-share {
.selected-area {
background-color: rgba(27, 103, 235, 0.036);
}
.shared-exp-div {
border: 1px solid rgb(230,230,230);
background-color: #fff;
&:hover {
.delete-exp {
visibility: visible;
}
}
}
.drag-handle {
color: rgb(194, 194, 194);
svg path {
stroke: #bbb;
}
}
.checkmark {
color: #fff;
visibility: hidden;
}
.delete-exp {
visibility: hidden;
}
tr {
transition: all 0.5s cubic-bezier(0.685, 0.0473, 0.346, 1);
}
.selected {
background-color: rgba(30, 181, 98, 0.139);
.checkmark {
color: rgb(30, 181, 98);
visibility: visible;
}
}
}
.new-presentations {
.metainfo {
font-size: 0.8em;
@@ -1056,9 +1098,35 @@ li.reports a span img {
.presentationcol {
.expimage {
max-width: 100%;
max-height: 400px;
max-height: 350px;
}
}
.presentation {
.subtitle {
text-align: center;
margin: 0;
padding: 6px;
font-weight: 400;
}
.text {
padding: 0;
margin: 0;
font-weight: 400;
}
.variation-result {
font-weight: bold;
}
}
.presentation-preview {
width: 100%;
height: 350px;
> div {
width: 200%;
height: 700px;
transform-origin: top left;
transform: scale(0.50);
}
}
@media (max-width: 850px) {
.leftBar {

100
yarn.lock
View File

@@ -386,6 +386,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.2":
version "7.14.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d"
integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/standalone@^7.4.5":
version "7.14.1"
resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.14.1.tgz#2c5f6908f03108583eea75bdcc94eb29e720fbac"
@@ -1383,6 +1390,14 @@
resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34"
integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/is-stream@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1"
@@ -1643,6 +1658,16 @@
dependencies:
"@types/react" "*"
"@types/react-redux@^7.1.16":
version "7.1.18"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.18.tgz#2bf8fd56ebaae679a90ebffe48ff73717c438e04"
integrity sha512-9iwAsPyJ9DLTRH+OFeIrm9cAbIj1i2ANL3sKQFATqnPWRbg+jEFXyZOKHiQK/N86pNRXbb4HRxAxo0SIX1XwzQ==
dependencies:
"@types/hoist-non-react-statics" "^3.3.0"
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react-syntax-highlighter@^11.0.4":
version "11.0.5"
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz#0d546261b4021e1f9d85b50401c0a42acb106087"
@@ -3840,6 +3865,13 @@ crypto-js@^4.0.0:
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.0.0.tgz#2904ab2677a9d042856a2ea2ef80de92e4a36dcc"
integrity sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==
css-box-model@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
dependencies:
tiny-invariant "^1.0.6"
css-color-keywords@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
@@ -5856,6 +5888,13 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
hosted-git-info@^2.1.4:
version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@@ -7709,7 +7748,7 @@ media-typer@0.3.0:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
memoize-one@^5.0.0:
memoize-one@^5.0.0, memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
@@ -9392,6 +9431,11 @@ quick-lru@^5.1.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
raf-schd@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
raf@^3.4.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -9439,6 +9483,19 @@ raw-body@2.4.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-beautiful-dnd@^13.1.0:
version "13.1.0"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz#ec97c81093593526454b0de69852ae433783844d"
integrity sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==
dependencies:
"@babel/runtime" "^7.9.2"
css-box-model "^1.2.0"
memoize-one "^5.1.1"
raf-schd "^4.0.2"
react-redux "^7.2.0"
redux "^4.0.4"
use-memo-one "^1.1.1"
react-bootstrap-typeahead@^5.0.0-rc.3:
version "5.1.4"
resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-5.1.4.tgz#856d30438bb7a254cfeafa3ec03ddf90285f7a4d"
@@ -9456,6 +9513,11 @@ react-bootstrap-typeahead@^5.0.0-rc.3:
scroll-into-view-if-needed "^2.2.20"
warning "^4.0.1"
react-colorful@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.3.0.tgz#bcbae49c1affa9ab9a3c8063398c5948419296bd"
integrity sha512-zWE5E88zmjPXFhv6mGnRZqKin9s5vip1O3IIGynY9EhZxN8MATUxZkT3e/9OwTEm4DjQBXc6PFWP6AetY+Px+A==
react-datepicker@^3.3.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-3.8.0.tgz#c3bccd3e3f47aa66864a2fa75651be097414430b"
@@ -9495,7 +9557,7 @@ react-icons@^4.2.0:
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.2.0.tgz#6dda80c8a8f338ff96a1851424d63083282630d0"
integrity sha512-rmzEDFt+AVXRzD7zDE21gcxyBizD/3NqjbX6cmViAgdqfJ2UiLer8927/QhhrXQV7dEj/1EGuOTPp7JnLYVJKQ==
react-is@16.13.1, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.8.1, react-is@^16.8.4:
react-is@16.13.1, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -9562,6 +9624,18 @@ react-rangeslider@^2.2.0:
classnames "^2.2.3"
resize-observer-polyfill "^1.4.2"
react-redux@^7.2.0:
version "7.2.4"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225"
integrity sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==
dependencies:
"@babel/runtime" "^7.12.1"
"@types/react-redux" "^7.1.16"
hoist-non-react-statics "^3.3.2"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^16.13.1"
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@@ -9757,6 +9831,13 @@ reduce-function-call@^1.0.1:
dependencies:
balanced-match "^1.0.0"
redux@^4.0.0, redux@^4.0.4:
version "4.1.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4"
integrity sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==
dependencies:
"@babel/runtime" "^7.9.2"
refractor@^2.4.1:
version "2.10.1"
resolved "https://registry.yarnpkg.com/refractor/-/refractor-2.10.1.tgz#166c32f114ed16fd96190ad21d5193d3afc7d34e"
@@ -10602,10 +10683,10 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65"
integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==
spectacle@^8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/spectacle/-/spectacle-8.2.0.tgz#82092750877dbd86f29ff9a9ef26b6771af95f8d"
integrity sha512-9doMqOKRCK7HVpmgtSmh+6HB2U+3c7WrgFlEAT18n/2mAP28kH22xZFsbjrdBl91Cz6id5U5IlrTVa094oaPiQ==
spectacle@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/spectacle/-/spectacle-8.3.0.tgz#17d235fd180fb88126570dd2b8063045204cabcd"
integrity sha512-Gcsg7AGKRltOhOMkmnHCmau8Q20cCYJz2F/VnIBoZbLhFgrTNPgc6y35672m+bKiYPool+1h9gMP9h7Rp0Iu3Q==
dependencies:
broadcast-channel "^3.2.0"
broadcastchannel-polyfill "^1.0.0"
@@ -11205,7 +11286,7 @@ tiny-emitter@^2.0.0:
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
tiny-invariant@^1.0.2:
tiny-invariant@^1.0.2, tiny-invariant@^1.0.6:
version "1.1.0"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==
@@ -11755,6 +11836,11 @@ use-latest@^1.0.0:
dependencies:
use-isomorphic-layout-effect "^1.0.0"
use-memo-one@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20"
integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==
use-resize-observer@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-6.1.0.tgz#d4d267a940dbf9c326da6042f8a4bb8c89d29729"