Skip to content

Commit

Permalink
MVP of the narratives debugger
Browse files Browse the repository at this point in the history
This implements new functionality in auspice available at /edit/narratives
where a user can drag&drop a markdown narrative file & see an overview
of the slides and the datasets they request, as well as preview the
narrative.

This represents a release-ready MVP of the functionality and opens
the door to a number of possible incremental improvements with the
eventual aim of writing and releasing a narrative entirely within-
browser.
  • Loading branch information
jameshadfield committed Nov 9, 2022
1 parent 3595926 commit 3ddbd16
Show file tree
Hide file tree
Showing 14 changed files with 976 additions and 1 deletion.
3 changes: 3 additions & 0 deletions narratives/invalid_1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[First slide](https://nextstrain.org/zika)

Note that we have no YAML frontmatter, so this is not a valid narrative!
8 changes: 8 additions & 0 deletions narratives/invalid_2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
foo: bar
---

[First slide](https://nextstrain.org/zika)

Minimal YAML frontmatter - too minimal ;)

2 changes: 1 addition & 1 deletion src/actions/loadData.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function addEndOfNarrativeBlock(narrativeBlocks) {
* This function returns `Dataset` objects for each of the different datasets in a narrative.
* Note: "initial": starting page, potentially different to frontmatter
*/
function parseNarrativeDatasets(blocks, query) {
export function parseNarrativeDatasets(blocks, query) {
let [datasets, initialNames, frontmatterNames] = [{}, [], []];
const initialBlockIdx = getNarrativePageFromQuery(query, blocks);
blocks.forEach((block, idx) => {
Expand Down
13 changes: 13 additions & 0 deletions src/actions/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { errorNotification } from "./notifications";
*/
export const chooseDisplayComponentFromURL = (url) => {
const parts = url.toLowerCase().replace(/^\/+/, "").replace(/\/+$/, "").split("/");
// todo - use URL() not the above code, but `url` is not actually the URL so...

if (isNarrativeEditor(parts)) return "debugNarrative";
if (
!parts.length ||
(parts.length === 1 && parts[0] === "") ||
Expand Down Expand Up @@ -131,3 +134,13 @@ export const goTo404 = (errorMessage) => ({
errorMessage,
pushState: true
});

/** The narratives editor is currently only a debugger (and named as such internally)
* however over time editing capability will be built out. The current proposal is for
* pathnames such as:
* /edit/narratives (the drag & drop interface, implemented here)
* /edit/{pathname} (future, not-yet-implemented functionality)
*/
function isNarrativeEditor(parts) {
return (parts.length===2 && parts[0]==="edit" && parts[1]==="narratives");
}
58 changes: 58 additions & 0 deletions src/components/narrativeEditor/NarrativeViewHeader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import styled from 'styled-components';
import React from "react";
import { connect } from "react-redux";
import { calcStyles } from "../main/utils";

/** The escape hatch to get back to debugging.
* This is complicated because auspice uses a lot of absolute CSS positioning, and
* attempting to unravel that is more trouble than it's worth at the moment.
* Currently it's designed to render over the top of the (narrative) sidebar,
* thus obscuring the title etc, which I think is an acceptable MVP. Eventually
* we want lots of options here so maybe a hamburger menu etc is necessary?
* There's plenty of improvements to be made, and perhaps there's even an entirely
* different escape hatch we could use.
*/

const OuterContainer = styled.div`
min-height: 50px; /* looks good with the nextstrain.org header */
background-color: #fd8d3c; /* same as "experimental" banner */
color: white; /* same as "experimental" banner */
font-size: 24px;
position: absolute;
display: flex; /* so we can vertically center the text */
flex-direction: column;
justify-content: center;
z-index: 100;
top: 0;
width: ${(props) => props.width+"px"};
max-width: ${(props) => props.width+"px"};
overflow-y: auto;
overflow-x: hidden;
`;

const InnerContainer = styled.div`
text-align: center;
cursor: pointer;
`;

@connect((state) => ({
displayNarrative: state.narrative.display,
browserDimensions: state.browserDimensions.browserDimensions
}))
class NarrativeViewHeader extends React.Component {
render() {
/* mobile display doesn't work well with this component, but then the whole editing functionality doesn't
play nicely with mobile (and I don't really see how it can...) */
const {sidebarWidth} = calcStyles(this.props.browserDimensions, this.props.displayNarrative, true, false);
// todo - can we surround this with an error boundary?
return (
<OuterContainer width={sidebarWidth}>
<InnerContainer style={{cursor: "pointer"}} onClick={() => this.props.setDisplayNarrative(false)}>
Return to debugging window
</InnerContainer>
</OuterContainer>
);
}
}

export default NarrativeViewHeader;
275 changes: 275 additions & 0 deletions src/components/narrativeEditor/examineNarrative.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import React, { useRef, useEffect } from "react";
import { useDispatch } from 'react-redux';
import { getDatasetNamesFromUrl } from "../../actions/loadData";
import { CLEAN_START } from "../../actions/types";
import { createStateFromQueryOrJSONs } from "../../actions/recomputeReduxState";
import * as Styles from "./styles";


const DatasetHover = ({lines}) => {
return (
<div>
{lines.map((l) => (<p key={l}>{l}</p>))}
</div>
);
};

const formatStatus = (status) => {
if (status && status.startsWith("Error")) return status;
if (status && status.startsWith("Warning")) return status;
else if (status==="inProgress") return "This file is being fetched as we speak...";
else if (status==="success") return "Success! This file exists and has been fetched. Note that there may still be subtle errors with the data, so we still suggest previewing the slide to double check.";
else if (status==="notAttempted") return "Status: We haven't attempted to fetch this file, as we are either waiting on the main JSON or this file is not necessary.";
// only remaining status is (should be) "internalError"
return "Oops, there has been some internal error here! Please consider letting us know about this.";
};

/**
* Summary of the load status of an individual dataset.
*/
const DatasetSummary = ({slide, name, statuses, showSlide}) => {
const n = slide.slideNumber;
return (
<div>
<div style={{display: "flex", justifyContent: "space-between", cursor: "pointer"}} onClick={showSlide}>
<Styles.SlideDataset data-tip data-for={`dataset_${n}_${name}`}>
{name}
</Styles.SlideDataset>
<div style={{display: "flex", flexDirection: "row"}}>
<Styles.DatasetIcon
num={n}
datasetType="main"
status={statuses?.main}
hoverContent={<DatasetHover lines={["The main dataset (auspice) JSON, which is necessary for this slide to display properly.", formatStatus(statuses?.main)]}/>}
/>
<Styles.DatasetIcon
num={n}
datasetType="rootSeq"
status={statuses?.rootSeq}
hoverContent={<DatasetHover lines={["An optional (sidecar) JSON which defines the root sequence. This is only needed for certain use-cases and the absence of this file is relatively normal.", formatStatus(statuses?.rootSeq)]}/>}
/>
<Styles.DatasetIcon
num={n}
datasetType="frequencies"
status={statuses?.frequencies}
hoverContent={<DatasetHover lines={["The frequencies JSON, which is only fetched when the dataset asks for the frequencies panel to be displayed.", formatStatus(statuses?.frequencies)]}/>}
/>
</div>
</div>
<Styles.HoverBox place="bottom" effect="solid" key={`hover_dataset_${name}`} id={`dataset_${n}_${name}`}>
<DatasetHover
lines={statuses?.main==="success" ?
[
"This dataset looks like it has loaded OK",
`The query parameters (which control the view) are ${slide.queryParams.toString()}`,
"Click here to view this slide full screen in order to check how the slide will be displayed in a narrative"
] :
[
"This dataset does't appear to be available or loaded correctly (hover over icons to the right for more details)",
"You can still click here to load the narrative slide but errors are to be expected!"
]}
/>
</Styles.HoverBox>
</div>
);
};

/**
* The component to render what dataset(s) + sidecars have been successfully
* fetched. If a mainDisplayMarkdown is to be used, this is displayed here.
* The user should be able to understand which datasets haven't loaded, and to
* debug this, through interactions with this component.
*/
const VizSummary = ({slide, datasetResponses, showSlide}) => {
const names = slide.associatedDatasetNames;
/* Do we have a tanglegram defined for this slide? */
const isTanglegram = !!names[1];
return (
<Styles.SlideDatasetContainer>
{(isTanglegram && (<div>Tanglegram</div>))}
<DatasetSummary slide={slide} key={names[0]} name={names[0]} statuses={datasetResponses[names[0]] || {}} showSlide={showSlide}/>
{(isTanglegram && (
<DatasetSummary slide={slide} key={names[1]} name={names[1]} statuses={datasetResponses[names[1]] || {}} showSlide={showSlide}/>
))}
</Styles.SlideDatasetContainer>
);
};

/**
* The component to render what sidebar (markdown) text is displayed
*/
const TextSummary = ({slide, n, onClick}) => {
return (
<Styles.SlideDescription onClick={onClick}>

<div data-tip data-for={`hover_slide_${n}`}>
{`Slide ${n+1}: ${extractTitleFromHTML(slide.blockHTML)}`}
</div>

<Styles.HoverBox place="bottom" effect="solid" key={`hover_slide_${n}`} id={`hover_slide_${n}`}>
<div dangerouslySetInnerHTML={{__html: slide.blockHTML}} /> {/* eslint-disable-line react/no-danger */}
</Styles.HoverBox>

</Styles.SlideDescription>
);
};


const ExplainSlides = () => (
<Styles.Introduction>
The <em>slides</em> column shows the title of each slide in the narrative.
Hovering will show you a preview of how the markdown content will appear, while clicking clicking on the title
will preview the entire narrative at that slide (you can click in the top-left
of the narrative to return to this editor).
<br/>
The <em>datasets</em> for each slide are shown to the right. Each dataset may comprise multiple
JSON files, the status of each is shown by icons to the right (hover for more details of
the status of individual files). Clicking will preview the entire narrative at that slide.
</Styles.Introduction>
);

const NarrativeSummary = ({summary, datasetResponses, showNarrative}) => {
return (
<>
<Styles.GridContainer>
{/* HEADER ROW */}
<Styles.GridHeaderCell key={'description'}>
Slides
</Styles.GridHeaderCell>
<Styles.GridHeaderCell key={'dataset'}>
Datasets (per slide)
</Styles.GridHeaderCell>
{/* ONE ROW PER SLIDE */}
{summary.reduce(
(els, slide) => {
const n = parseInt(slide.slideNumber, 10);
els.push(
<TextSummary key={`slide_${n}`} slide={slide} n={n} onClick={() => showNarrative(n)}/>
);
els.push(
<VizSummary key={`dataset_${n}`} slide={slide} datasetResponses={datasetResponses} showSlide={() => showNarrative(n)}/>
);
return els;
},
[]
)}
</Styles.GridContainer>
</>
);
};

const ExamineNarrative = ({narrative, datasetResponses, setDisplayNarrative}) => {
const dispatch = useDispatch();
const el = useRef(null);
useEffect(() => {
/* when a narrative is loaded then we want to focus on <ExamineNarrative> however
on laptop screens this can be below the fold and thus not apparent. Solve this
by scrolling it into view on load time */
el.current.scrollIntoView();
}, [el]);

const summary = narrative.blocks.map((block, idx) => {
return {
blockHTML: block.__html,
associatedDatasetNames: getDatasetNamesFromUrl(block.dataset),
queryParams: parseQuery(block.query),
slideNumber: String(idx)
};
});

/**
* Show the narrative at this slide. This is base upon the `loadNarrative` function (loadData.js).
* As the debug narrative page is still a prototype I attempted to avoid modifying too many existing auspice
* functions, but in the future we should refactor `loadNarrative` so that we can reuse
* the core functionality.
* We explicity make a CLEAN_START each time so we don't have to worry about
* previous redux state (e.g. from a previous narrative in the editor)
*
* There is one (subtle) difference between loading a normal narrative and
* this approach with regards to the initial dataset if the frontmatter dataset
* isn't valid and the 1st slide's dataset is (and vice versa). As we're debugging
* I want us to flag this as an error rather than being clever about it.
* @param {int} n
*/
const showNarrative = async (n=0) => {
const datasetNames = summary[n].associatedDatasetNames;

let cleanStartAction;
try {
cleanStartAction = {
type: CLEAN_START,
pathnameShouldBe: undefined,
preserveCache: true, // specific for the narrative debugger
...createStateFromQueryOrJSONs({
json: await narrative.datasets[datasetNames[0]].main,
secondTreeDataset: datasetNames[1] ? await narrative.datasets[datasetNames[1]].main : undefined,
query: n ? {n} : {}, // query is things like n=3 to load a specific page
narrativeBlocks: narrative.blocks,
mainTreeName: narrative.datasets[narrative.initialNames[0]].pathname,
secondTreeName: datasetNames[1] || null,
dispatch
})
};
} catch (err) {
// todo
console.error(`Failed to load the narrative on slide ${n}`, err);
return;
}
dispatch(cleanStartAction);
setDisplayNarrative(true);

/* the CLEAN_START action _does not_ consider sidecars, so we do this manually... */
narrative.datasets[datasetNames[0]].loadSidecars(dispatch);
};

return (
<div ref={el}>
<ExplainSlides/>
<NarrativeSummary summary={summary} datasetResponses={datasetResponses} showNarrative={showNarrative}/>
<Styles.ButtonContainer>
<Styles.Button onClick={() => showNarrative()}>
View Narrative
</Styles.Button>
</Styles.ButtonContainer>
<div style={{minHeight: "50px"}}/>
</div>
);
};

/**
* Our narrative parsing functions don't return the underlying markdown, but
* because we control the HTML output we can make certain assumptions about
* where the title is. This function should be considered fit for purpose only
* for the MVP -- we should instead modify `parseMarkdownNarrativeFile`
* to return this information
* @param {string} html
* @returns {string} the title
*/
function extractTitleFromHTML(html) {
const reFrontmatterSlide = /^<h2[^>]+>(.+?)<\/h2>/;
const reNormalSlide = /^<h1[^>]+>(.+?)<\/h1>/;
try {
if (html.match(reFrontmatterSlide)) {
return html.match(reFrontmatterSlide)[1];
}
if (html.match(reNormalSlide)) {
return html.match(reNormalSlide)[1];
}
return "Unknown slide title";
} catch (err) {
console.error(`Error extracting title:`, err.message);
return "Unknown slide title";
}
}

function parseQuery(queryString) {
try {
return (new URL(`http://foo.com?${queryString}`).searchParams);
} catch (err) {
console.error(`Error parsing queryString ${queryString}`, err);
return "Unknown query";
// Todo - flag to user!
}
}

export default ExamineNarrative;
Loading

0 comments on commit 3ddbd16

Please sign in to comment.