Created
March 1, 2022 11:43
-
-
Save joduplessis/2bcd93ccc1affaac1fa546e7fb7bf7b8 to your computer and use it in GitHub Desktop.
The Adtriba coding challenge. Form solution + draggable input curve.
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
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="UTF-8" /> | |
<style> | |
* { | |
font-family: sans-serif; | |
outline: none; | |
text-decoration: none; | |
font-size: 13px; | |
-webkit-font-smoothing: antialiased; | |
font-feature-settings: 'liga', 'kern'; | |
text-rendering: optimizeLegibility; | |
} | |
body { | |
background-color: #FAFAFA; | |
} | |
.container { | |
background: white; | |
padding: 20px; | |
margin-left: auto; | |
margin-right: auto; | |
margin-top: 10px; | |
margin-bottom: 10px; | |
width: 600px; | |
border-radius: 5px; | |
} | |
.buttons { | |
flex-direction: row; | |
align-items: center; | |
align-content: center; | |
display: flex; | |
justify-content: flex-end; | |
box-sizing: border-box; | |
} | |
.buttons button:last-child { | |
margin-left: 5px; | |
} | |
.row { | |
flex-direction: row; | |
align-items: center; | |
align-content: center; | |
display: flex; | |
justify-content: center; | |
box-sizing: border-box; | |
width: 100%; | |
} | |
.error-message { | |
font-size: 10px; | |
background-color: #EF4056; | |
font-weight: 600; | |
margin-bottom: 20px; | |
color: white; | |
padding: 10px; | |
border-radius: 4px; | |
font-family: 'menlo', monospace; | |
} | |
.column { | |
flex-direction: column; | |
align-items: flex-start; | |
align-content: center; | |
display: flex; | |
justify-content: center; | |
flex: 1; | |
padding: 10px; | |
} | |
label { | |
display: block; | |
margin-bottom: 5px; | |
color: #202428; | |
font-size: 11px; | |
font-weight: 900; | |
font-family: 'menlo', monospace; | |
} | |
input { | |
display: block; | |
padding: 10px; | |
border-radius: 7px; | |
border: 4px solid #f1f3f5; | |
color: #202428; | |
box-sizing:border-box; | |
font-size: 16px; | |
font-weight: 500; | |
width: 100%; | |
} | |
input.error { | |
border: 4px solid #EF4056; | |
} | |
input::placeholder { | |
color: #adb5bd; | |
} | |
.coordinates, .highlight { | |
color: #8e43e7; | |
font-size: 11px; | |
font-weight: 500; | |
font-family: 'menlo', monospace; | |
} | |
button { | |
color: white; | |
background: #8e43e7; | |
font-size: 13px; | |
font-weight: 700; | |
padding: 10px; | |
border: none; | |
border-radius: 7px; | |
cursor: pointer; | |
} | |
table { | |
width: 100%; | |
box-sizing: border-box; | |
margin-top: 30px; | |
margin-bottom: 30px; | |
} | |
table th { | |
padding: 10px; | |
font-size: 11px; | |
font-weight: 900; | |
font-family: 'menlo', monospace; | |
} | |
table td.title { | |
color: #202428; | |
font-weight: bold; | |
} | |
.chart { | |
flex-direction: row; | |
align-items: center; | |
align-content: center; | |
display: flex; | |
justify-content: center; | |
} | |
</style> | |
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script> | |
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script> | |
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
<script src="https://d3js.org/d3.v4.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/TweenMax.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/utils/Draggable.min.js"></script> | |
</head> | |
<body> | |
<div id="app"></div> | |
<script type="text/babel"> | |
const EXCLUDE = "EXCLUDE" | |
const CONSTANT = "CONSTANT" | |
const validateCurrency = (value) => { | |
const regex = /^\d+(?:\.\d{0,2})$/ | |
if (regex.test(value)) return true | |
return false | |
} | |
const Row = ({ title, updateTotal }) => { | |
const [amount, setAmount] = React.useState('') | |
const [type, setType] = React.useState('') | |
const [error, setError] = React.useState(false) | |
return ( | |
<tr> | |
<td className="title">{title}</td> | |
<td> | |
<input | |
className={error ? "error": ""} | |
value={amount} | |
placeholder="0.00" | |
type="text" | |
onChange={e => setAmount(e.target.value)} | |
onBlur={e => { | |
if (validateCurrency(amount)) { | |
setError(false) | |
updateTotal(amount) | |
} else { | |
setError(true) | |
} | |
}} | |
/> | |
</td> | |
<td> | |
<input | |
checked={type == CONSTANT} | |
type="checkbox" | |
onChange={() => type == CONSTANT ? setType('') : setType(CONSTANT)} | |
/> | |
</td> | |
<td> | |
<input | |
checked={type == EXCLUDE} | |
type="checkbox" | |
onChange={() => type == EXCLUDE ? setType('') : setType(EXCLUDE)} | |
/> | |
</td> | |
</tr> | |
) | |
} | |
class App extends React.Component { | |
constructor(props) { | |
super(props) | |
this.state = { | |
name: '', | |
start: '', | |
end: '', | |
error: '', | |
sea: 0, | |
display: 0, | |
social: 0, | |
affiliate: 0, | |
remarketing: 0, | |
x: 0, | |
y: 0, | |
} | |
this.copyOrSave = this.copyOrSave.bind(this) | |
this.chartRef = React.createRef() | |
this.getTotal = this.getTotal.bind(this) | |
} | |
copyOrSave() { | |
this.setState({ error: '' }) | |
// Check the null values! | |
if ( | |
this.state.name == '' | |
|| !this.state.start | |
|| !this.state.end | |
|| !this.state.sea | |
|| !this.state.display | |
|| !this.state.social | |
|| !this.state.affiliate | |
|| !this.state.remarketing | |
) { | |
return this.setState({ error: 'Please complete all the details!' }) | |
} | |
const startDate = new Date(this.state.start).getTime() | |
const endDate = new Date(this.state.end).getTime() | |
// Check that the date make sense | |
if (startDate >= endDate) { | |
return this.setState({ error: 'These dates are incorrect' }) | |
} | |
// Otherwise all good | |
alert('Done!') | |
} | |
getTotal() { | |
// These should all be validating correctly from the child component | |
const total = parseFloat(this.state.sea) | |
+ parseFloat(this.state.display) | |
+ parseFloat(this.state.social) | |
+ parseFloat(this.state.affiliate) | |
+ parseFloat(this.state.remarketing) | |
return (Math.round(total * 100) / 100).toFixed(2) | |
} | |
componentDidMount() { | |
this.createChart() | |
} | |
createChart() { | |
const margin = { top: 10, right: 30, bottom: 30, left: 60 } | |
const width = 460 - margin.left - margin.right | |
const height = 400 - margin.top - margin.bottom | |
const svg = d3.select(this.chartRef) | |
.append("svg") | |
.attr("width", width + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom) | |
.append("g") | |
.attr("transform", | |
"translate(" + margin.left + "," + margin.top + ")"); | |
d3.csv("https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/data_IC.csv", (data) => { | |
const x = d3 | |
.scaleLinear() | |
.domain([1,100]) | |
.range([ 0, width ]) | |
svg.append("g") | |
.attr("transform", "translate(0," + height + ")") | |
.call(d3.axisBottom(x)) | |
// Add Y axis | |
const y = d3 | |
.scaleLinear() | |
.domain([0, 13]) | |
.range([ height, 0 ]) | |
svg.append("g").call(d3.axisLeft(y)) | |
// Show confidence interval | |
svg.append("path") | |
.datum(data) | |
.attr("fill", "#f4ebff") | |
.attr("stroke", "none") | |
.attr("d", d3.area() | |
.x(function(d) { return x(d.x) }) | |
.y0(function(d) { return y(d.CI_right) }) | |
.y1(function(d) { return y(d.CI_left) }) | |
) | |
// Add the line | |
svg | |
.append("path") | |
.datum(data) | |
.attr("id", "rail") | |
.attr("fill", "none") | |
.attr("stroke", "#8e43e7") | |
.attr("stroke-width", 1.5) | |
.attr("d", d3.line() | |
.x(function(d) { return x(d.x) }) | |
.y(function(d) { return y(d.y) }) | |
) | |
const circle = svg.append("circle") | |
.attr("fill", "black") | |
.attr("id", "knob") | |
.attr("cx", 0) | |
.attr("cy", height - 0) | |
.attr("r", 5) | |
const rail = document.getElementById("rail") | |
const knob = document.getElementById("knob") | |
const W = rail.getBBox().width | |
const railLength = rail.getTotalLength() | |
// So we still have access to the class | |
const context = this | |
// Important here - we need access to 'this' | |
function update (e) { | |
const P = rail.getPointAtLength(this.x / W*railLength); | |
TweenLite.set(knob, { attr: { cx:P.x, cy:P.y }}) | |
context.setState({ | |
x: P.x, | |
y: P.y, | |
}) | |
} | |
Draggable.create(document.createElement('div'), { | |
type:'x', | |
throwProps:true, | |
bounds:{ minX: 0, maxX: W }, | |
trigger: knob, | |
overshootTolerance: 0, | |
onDrag: update, | |
onThrowUpdate: update | |
}) | |
}) | |
} | |
render() { | |
return ( | |
<React.Fragment> | |
<div className="container"> | |
{!!this.state.error && ( | |
<div className="error-message">{this.state.error}</div> | |
)} | |
<div className="row"> | |
<div className="column"> | |
<label> | |
Mediaplan - <span className="highlight">USD {this.getTotal()}</span> | |
</label> | |
<input | |
type="text" | |
value={this.state.name} | |
onChange={e => this.setState({ name: e.target.value })} | |
/> | |
</div> | |
</div> | |
<div className="row"> | |
<div className="column"> | |
<label>Start date</label> | |
<input | |
value={this.state.start} | |
type="date" | |
onChange={e => this.setState({ start: e.target.value })} | |
/> | |
</div> | |
<div className="column"> | |
<label>End date</label> | |
<input | |
value={this.state.end} | |
type="date" | |
onChange={e => this.setState({ end: e.target.value })} | |
/> | |
</div> | |
</div> | |
<table> | |
<thead> | |
<tr> | |
<th>Channel</th> | |
<th>Budget</th> | |
<th>Keep constant</th> | |
<th>Exclude</th> | |
</tr> | |
</thead> | |
<tbody> | |
<Row | |
title="SEA" | |
updateTotal={(sea) => this.setState({ sea })} | |
/> | |
<Row | |
title="Display" | |
updateTotal={(display) => this.setState({ display })} | |
/> | |
<Row | |
title="Social" | |
updateTotal={(social) => this.setState({ social })} | |
/> | |
<Row | |
title="Affiliate" | |
updateTotal={(affiliate) => this.setState({ affiliate })} | |
/> | |
<Row | |
title="Remarketing" | |
updateTotal={(remarketing) => this.setState({ remarketing })} | |
/> | |
</tbody> | |
</table> | |
<div className="buttons"> | |
<button onClick={this.copyOrSave}>Copy plan</button> | |
<button onClick={this.copyOrSave}>Save plan</button> | |
</div> | |
</div> | |
<div className="container"> | |
<div className="chart" ref={ref => this.chartRef = ref}> | |
</div> | |
<div className="coordinates"> | |
x: {this.state.x}, y: {this.state.y} | |
</div> | |
</div> | |
</React.Fragment> | |
) | |
} | |
} | |
const domContainer = document.querySelector('#app'); | |
ReactDOM.render(<App />, domContainer); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment