mirror of
https://github.com/growthbook/growthbook.git
synced 2021-08-07 14:23:53 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
34
packages/back-end/types/presentation.d.ts
vendored
34
packages/back-end/types/presentation.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
319
packages/front-end/components/Share/Presentation.tsx
Normal file
319
packages/front-end/components/Share/Presentation.tsx
Normal 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;
|
||||
75
packages/front-end/components/Share/Preview.tsx
Normal file
75
packages/front-end/components/Share/Preview.tsx
Normal 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'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;
|
||||
870
packages/front-end/components/Share/ShareModal.tsx
Normal file
870
packages/front-end/components/Share/ShareModal.tsx
Normal 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;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
100
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user