-
Notifications
You must be signed in to change notification settings - Fork 166
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
3595926
commit 3ddbd16
Showing
14 changed files
with
976 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ;) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.