Last active
November 23, 2016 13:14
-
-
Save benhoyle/a72454d4beb517991cb6431511c82888 to your computer and use it in GitHub Desktop.
Editable React Panel
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
<body> | |
<div class="container-fluid" id="container"></div> | |
</body> |
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
/* ----------- BUTTONS START ----------- */ | |
class ButtonGroup extends React.Component { | |
render() { | |
return ( | |
<div className="btn-group btn-group-sm"> | |
{this.props.buttons} | |
</div> | |
); | |
} | |
} | |
class EditButton extends React.Component { | |
render() { | |
return ( | |
<button type="button" onClick={this.props.onClick} className="edit btn btn-default" ><span className="glyphicon glyphicon-pencil"></span></button> | |
); | |
} | |
} | |
class DeleteButton extends React.Component { | |
render() { | |
return ( | |
<button type="button" onClick={this.props.onClick} className="edit btn btn-default" ><span className="glyphicon glyphicon-trash"></span></button> | |
); | |
} | |
} | |
class ConfirmButton extends React.Component { | |
render() { | |
return ( | |
<button type="button" onClick={this.props.onClick} className="edit btn btn-default btn-success" ><span className="glyphicon glyphicon-ok"></span></button> | |
); | |
} | |
} | |
class CancelButton extends React.Component { | |
render() { | |
return ( | |
<button type="button" onClick={this.props.onClick} className="edit btn btn-default btn-danger" ><span className="glyphicon glyphicon-remove"></span></button> | |
); | |
} | |
} | |
class FullWidthButton extends React.Component { | |
render() { | |
return ( | |
<button type="button" onClick={this.props.onClick} className="btn btn-default btn-block" >{this.props.buttontext}</button> | |
); | |
} | |
} | |
class FullWidthLinkButton extends React.Component { | |
render() { | |
if (this.props.disabled) { | |
var class_name = "btn btn-default btn-block disabled" | |
} else { | |
var class_name = "btn btn-default btn-block" | |
} | |
return ( | |
<a href={this.props.link} className={class_name}>{this.props.buttontext}</a> | |
); | |
} | |
} | |
/* ----------- BUTTONS END ----------- */ | |
/* ------------ RENDERING FUNCTIONS ----------- */ | |
function CellRender(props) { | |
return ( | |
<td onClick={props.onClick}>{props.value}</td> | |
); | |
} | |
function ParagraphRender(props) { | |
return ( | |
<p>{props.value}</p> | |
); | |
} | |
function ParagraphInputText(props) { | |
// Props = label, id, placeholder, value, onChange | |
return ( | |
<p> | |
<label for={props.id}>{props.label}</label> | |
<input | |
className="form-control input-sm" | |
value={props.value} | |
id={props.id} | |
placeholder={props.placeholder} | |
type="text" | |
onChange={props.handleFieldChange}></input> | |
</p> | |
); | |
} | |
function ParagraphInputTextArea(props) { | |
// Props = label, id, placeholder, value, onChange | |
return ( | |
<p> | |
<label for={props.id}>{props.label}</label> | |
<textarea | |
className="form-control input-sm" | |
value={props.value} | |
id={props.id} | |
placeholder={props.placeholder} | |
type="text" | |
onChange={props.handleFieldChange}> | |
</textarea> | |
</p> | |
); | |
} | |
function TableCellInputText(props) { | |
// Props = label, id, placeholder, value, onChange | |
return ( | |
<td> | |
<input | |
className="form-control input-sm" | |
type="text" | |
value={props.value} | |
id={props.id} | |
placeholder={props.placeholder} | |
onChange={props.handleFieldChange}> | |
</input> | |
</td> | |
); | |
} | |
/* ------------ RENDERING FUNCTIONS END ------- */ | |
/* ------------ HOC DEFINITIONS ----------- */ | |
var DisplayField = (Wrapped) => class extends React.Component { | |
//Props = field and onClick | |
render() { | |
return ( | |
<Wrapped | |
value = {this.props.field.value} | |
onClick = {this.props.onClick} | |
/> | |
); | |
} | |
} | |
var EditField = (Wrapped) => class extends React.Component { | |
// Props: field, sendValueToParent | |
constructor(props) { | |
super(props); | |
this.state = { | |
value: this.props.field.value | |
}; | |
this.handleFieldChange = this.handleFieldChange.bind(this); | |
} | |
handleFieldChange(e) { | |
this.setState({ | |
value: e.target.value | |
}); | |
// Send value back to row - note: state is too slow | |
this.props.sendValueToParent(this.props.field.name, e.target.value); | |
} | |
render() { | |
return ( | |
<Wrapped | |
label={this.props.field.header} | |
value={this.state.value} | |
id={this.props.field.name} | |
placeholder={this.props.field.placeholder} | |
onChange={this.handleFieldChange} | |
/> | |
); | |
} | |
} | |
// So to return a Display field for a panel I have | |
// var DisplayField = DisplayField(ParagraphRender); | |
// This is passed as props? | |
// var buttons = [<EditButton onClick={this.props.handleEditModeClick}/>, <DeleteButton onClick={this.props.handleDeleteClick}/>]; | |
class DisplayInstance extends React.Component { | |
// Props - render, instance data, handleSelect | |
// | |
render() { | |
var render_fields = []; | |
const DF = DisplayField(this.props.render); | |
this.props.instancedata.forEach(function(field) { | |
render_fields.push(<DF onClick={this.props.handleSelect} field={field} key={field.name}/>); | |
}, this); | |
// Set selected status - this is props - return method | |
return ( | |
<div> | |
{render_fields} | |
</div> | |
); | |
} | |
} | |
/*var field = { | |
header: fd.header, | |
length: fd.length, | |
name: fd.name, | |
placeholder: fd.placeholder, | |
value: this.state.instance[fd.name] | |
};*/ | |
// What if we want different renders for different fields? | |
class EditInstance extends React.Component { | |
// Props = instancedata - list of fields as above | |
// editrender and displayrender | |
render() { | |
var render_fields = []; | |
// Do these need to be passed as a list of renders? | |
var EF = EditField(this.props.editrender); | |
var DF = DisplayField(this.props.displayrender); | |
this.props.instancedata.forEach(function(field) { | |
if (field.inputfield) { | |
console.log(field.inputfield); | |
render_fields.push(<EF field={field} key={field.name} sendValueToParent={this.props.updateValues}/>); | |
} else { | |
render_fields.push(<DF onClick={""} field={" "} key={field.name}/>); | |
}; | |
}, this); | |
return ( | |
<span>{render_fields}</span> | |
); | |
} | |
} | |
var InstanceContainer = (Wrapped) => class extends React.Component { | |
// This is too complicated - need to deconstruct | |
// Props = fielddata, instancedata - [{"name":"value}, ..] | |
constructor(props) { | |
super(props); | |
this.handleSelectRow = this.handleSelectRow.bind(this); | |
this.handleEditModeClick = this.handleEditModeClick.bind(this); | |
this.handleExitEditModeClick = this.handleExitEditModeClick.bind(this); | |
this.handleDeleteClick = this.handleDeleteClick.bind(this); | |
this.handleConfirmEditClick = this.handleConfirmEditClick.bind(this); | |
this.updateValues = this.updateValues.bind(this); | |
var editMode = false; | |
var added = false; | |
// If no created date then row is a newly added row | |
if (!this.props.instance.date_created) { | |
editMode = true; | |
added = true; | |
} | |
console.log(this.props.fielddata); | |
console.log(this.props.instance); | |
var instance = {}; | |
this.props.fielddata.forEach(function(fd) { | |
instance[fd.name] = this.props.instance[fd.name]; | |
}, this); | |
instance.id = this.props.instance.id; | |
this.state = { | |
editMode: editMode, | |
instance: instance, | |
revised_instance: instance, | |
deleted: false, | |
added: added, | |
selected: false | |
}; | |
} | |
handleSelectRow() { | |
if (!this.state.selected) { | |
this.setState({ | |
selected: true | |
}); | |
} else { | |
this.setState({ | |
selected: false | |
}); | |
} | |
this.props.setSelected(this.props.instance.id); | |
} | |
handleEditModeClick() { | |
this.setState({ | |
editMode: true | |
}); | |
} | |
handleExitEditModeClick() { | |
this.setState({ | |
editMode: false | |
}); | |
this.setState({ | |
revised_instance: this.state.instance | |
}); | |
if (this.state.added) { | |
this.setState({ | |
deleted: true | |
}); | |
} | |
} | |
handleDeleteClick() { | |
this.setState({ | |
deleted: true | |
}); | |
console.log("AJAX DELETE"); | |
console.log(this.state.instance.id); | |
} | |
handleConfirmEditClick() { | |
// CONFIRM revised_instance | |
this.setState({ | |
editMode: false, | |
instance: this.state.revised_instance | |
}); | |
if (this.state.added) { | |
console.log("AJAX POST"); | |
console.log(revised_instance); | |
} else { | |
console.log("AJAX PATCH"); | |
console.log(revised_instance); | |
// Reset added flag | |
this.setState({ | |
added: false | |
}); | |
} | |
} | |
updateValues(key, value) { | |
// Method to update values passed from EditField | |
var temp_revised_instance = this.state.revised_instance; | |
temp_revised_instance[key] = value; | |
this.setState({ | |
revised_instance: temp_revised_instance | |
}); | |
} | |
render() { | |
let buttons = null; | |
let rendered_instance = null; | |
if (this.state.deleted) { | |
const deletedHTML = this.props.deletedHTML; | |
//<td></td> - for table | |
// <div></div> - for panel | |
return ({ | |
deletedHTML | |
}); | |
} | |
// Assemble metadata and values | |
var fields = []; | |
this.props.fielddata.forEach( | |
function(fd) { | |
fields.push({ | |
header: fd.header, | |
length: fd.length, | |
name: fd.name, | |
placeholder: fd.placeholder, | |
value: this.state.instance[fd.name] | |
}); | |
}, this); | |
if (this.state.editMode) { | |
buttons = [ | |
<ConfirmButton onClick={this.handleConfirmEditClick}/>, | |
<CancelButton onClick={this.handleExitEditModeClick}/> | |
]; | |
rendered_instance = <EditInstance instancedata ={fields} displayrender = {ParagraphRender} editrender ={ParagraphInputTextArea}/> | |
} else { | |
// In display mode - add edit/delete buttons | |
buttons = [ | |
<EditButton onClick={this.handleEditModeClick}/>, | |
<DeleteButton onClick={this.handleDeleteClick}/> | |
]; | |
rendered_instance = <DisplayInstance instancedata={fields} render = {ParagraphRender} /> | |
}; | |
return ( | |
<Wrapped | |
selected={this.props.instance.selected} | |
rendered_instance={rendered_instance} | |
buttons={buttons} | |
/> | |
); | |
} | |
} | |
function TableRow(props) { | |
return ( | |
<tr className={props.selected ? "success" : ""}> | |
{props.rendered_instance} | |
<ButtonGroup buttons={props.buttons}/> | |
</tr> | |
); | |
} | |
function PanelContent(props) { | |
return ( | |
<div className="row"> | |
<div id="datapane" className="col-sm-8 col-md-9 col-lg-10"> | |
{props.rendered_instance} | |
</div> | |
<div className="col-sm-4 col-md-3 col-lg-2 panelright"> | |
<ButtonGroup buttons={ props.buttons } /> | |
</div> | |
</div> | |
); | |
} | |
class TabGroup extends React.Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
activetab: 0 | |
}; | |
} | |
switchTab(index) { | |
console.log(index); | |
this.setState({ | |
activetab: index | |
}); | |
} | |
render() { | |
var tab_header = []; | |
this.props.tabs.forEach(function(tab) { | |
// Check if tab is active | |
var current_index = this.props.tabs.indexOf(tab); | |
if (current_index == this.state.activetab) { | |
// Tab is active | |
tab_header.push(<button className="btn btn-default active">{tab.title}</button>); | |
} else { | |
// Tab is not active | |
tab_header.push(<button className="btn btn-default">{tab.title}</button>); | |
} | |
}, this); | |
return ( | |
<div id="tabview" className="top-buffer"> | |
<div className="btn-group"> | |
{tab_header} | |
</div> | |
</div> | |
); | |
} | |
} | |
class PanelLeftPane extends React.Component { | |
//obj_type().charAt(0).toUpperCase() + obj_type().slice(1) | |
//"text: '(' + legal_basis() + ')'" | |
render() { | |
return ( | |
<div className="col-sm-3 col-lg-2"> | |
<div className="panelleftpane"> | |
<h4>{this.props.subtitle1}</h4> | |
<h5>{this.props.subtitle2}</h5> | |
</div> | |
</div> | |
); | |
} | |
} | |
class ChildPanel extends React.Component { | |
//Props: title | |
render() { | |
return ( | |
<div className="childpanel row"> | |
<div className="col-sm-9 col-sm-offset-3 col-lg-10 col-lg-offset-2"> | |
<Panel title={this.props.title}/> | |
</div> | |
</div> | |
); | |
} | |
} | |
class Panel extends React.Component { | |
/* Panels and table rows differ in: | |
- rendered HTML | |
- selection | |
Both are special cases of a more general EditableObject class? | |
*/ | |
/*Props - in an object structure? | |
paneldata = {title: "title", leftpanel={subtitle1: "substitle1", subtitle2: "substitle2"}, data=[]} | |
*/ | |
// Conditional display of left panel based on flag? | |
// Have an "edit" state like table row? | |
render() { | |
return ( | |
<div | |
className="row panel" | |
> | |
<legend>{this.props.title}</legend> | |
<PanelLeftPane | |
subtitle1={this.props.subtitle1} | |
subtitle2={this.props.substitle2} | |
/> | |
<PanelDisplayPane | |
data={[{text:"1"},{text:"2"},{text:"3"}]} | |
/> | |
<PanelRightPane buttons={""}/> | |
</div> | |
); | |
} | |
} | |
class PanelContainer extends React.Component { | |
render() { | |
//label, id, placeholder, value, onChange | |
return ( | |
<div className="panelcontainer"> | |
<Panel title={"Objection"}/> | |
<ChildPanel title={"Analysis"}/> | |
<ChildPanel title={"Amendments"}/> | |
<EditableInstance setSelectedRow={""} instance={objectiondata.instances[0]} fielddata={objectiondata.fielddata} key={objectiondata.instances[0].id}/> | |
</div> | |
); | |
} | |
} | |
class Objection extends React.Component { | |
// Has an instance and fielddata as props | |
render() { | |
return ( | |
"" | |
); | |
} | |
} | |
class Analysis extends React.Component { | |
// Has an instance and fielddata as props | |
render() { | |
return ( | |
"" | |
); | |
} | |
} | |
class Amendment extends React.Component { | |
// Has an instance and fielddata as props | |
render() { | |
return ( | |
"" | |
); | |
} | |
} | |
// Test data from API | |
var objectiondata = { | |
"api_uri": "http://localhost/oar/objections/data/", | |
"fielddata": [{ | |
"header": "Objection Type", | |
"inputfield": true, | |
"length": 10, | |
"name": "obj_type", | |
"placeholder": "" | |
}, { | |
"inputfield": true, | |
"length": 10, | |
"name": "legal_basis", | |
"placeholder": "Please provide the legal provision under which the objection is raised, e.g. Article 84 EPC." | |
}, { | |
"header": "Associated Section of Office Action", | |
"inputfield": true, | |
"length": 10, | |
"name": "oa_section", | |
"placeholder": "Please provide the section of the office action in which the objection is raised, e.g. 1 or 3.1." | |
}, { | |
"header": "Associated Part of Application", | |
"inputfield": true, | |
"length": 10, | |
"name": "appln_section", | |
"placeholder": "Please provide the objectionable section of the application, e.g. claim 1 or paragraph 2, page 12." | |
}, { | |
"header": "Summary of objection", | |
"inputfield": true, | |
"length": 20, | |
"name": "short_desc", | |
"placeholder": "Please provide a short description of the objection, e.g. Term X is deemed unclear." | |
}, { | |
"header": "Objection Reasoning", | |
"inputfield": true, | |
"length": 30, | |
"name": "reason", | |
"placeholder": "Please provide the Examiner's reasoning for the objection, e.g. Disclosed in paragraph X of D1." | |
}], | |
"instances": [{ | |
"appln_section": "fdgsdfgsdg", | |
"childlinks": [{ | |
"name": "Analysis", | |
"uri": "http://localhost/oar/analysis/data/?parent_id=1" | |
}, { | |
"name": "Amendment", | |
"uri": "http://localhost/oar/amendments/data/?parent_id=1" | |
}], | |
"date_created": "16 November 2016", | |
"date_modified": "16 November 2016", | |
"id": 1, | |
"legal_basis": "fsdgfdsg", | |
"oa_section": "fdgdfsgsd", | |
"obj_type": "novelty", | |
"parent_id": 1, | |
"reason": "dfgfdsgsdg", | |
"short_desc": "dsfgfdsgsd", | |
"uri": "http://localhost/oar/objections/data/1" | |
}, { | |
"appln_section": "sadfsaf", | |
"childlinks": [{ | |
"name": "Analysis", | |
"uri": "http://localhost/oar/analysis/data/?parent_id=2" | |
}, { | |
"name": "Amendment", | |
"uri": "http://localhost/oar/amendments/data/?parent_id=2" | |
}], | |
"date_created": "20 November 2016", | |
"date_modified": "20 November 2016", | |
"id": 2, | |
"legal_basis": "sdafsadf", | |
"oa_section": "sdafsdaf", | |
"obj_type": "clarity", | |
"parent_id": 1, | |
"reason": "safdsfsdf", | |
"short_desc": "sadfasdf", | |
"uri": "http://localhost/oar/objections/data/2" | |
}] | |
} | |
const analysisdata1 = { | |
"api_uri": "http://localhost/oar/analysis/data/", | |
"fielddata": [{ | |
"header": "Is objection valid?", | |
"inputfield": true, | |
"length": 10, | |
"name": "valid", | |
"placeholder": "" | |
}, { | |
"header": "Reason objection is valid / not valid", | |
"inputfield": true, | |
"length": 30, | |
"name": "reason", | |
"placeholder": "Please provide evidence that supports your conclusion" | |
}], | |
"instances": [{ | |
"childlinks": [], | |
"date_created": "16 November 2016", | |
"date_modified": "16 November 2016", | |
"id": 1, | |
"parent_id": 1, | |
"reason": "gfhfghfgh", | |
"uri": "http://localhost/oar/analysis/data/1", | |
"valid": false | |
}] | |
} | |
const analysisdata2 = { | |
"api_uri": "http://localhost/oar/analysis/data/", | |
"fielddata": [{ | |
"header": "Is objection valid?", | |
"inputfield": true, | |
"length": 10, | |
"name": "valid", | |
"placeholder": "" | |
}, { | |
"header": "Reason objection is valid / not valid", | |
"inputfield": true, | |
"length": 30, | |
"name": "reason", | |
"placeholder": "Please provide evidence that supports your conclusion" | |
}], | |
"instances": [] | |
} | |
const amendmentdata1 = { | |
"api_uri": "http://localhost/oar/amendments/data/", | |
"fielddata": [{ | |
"header": "Associated Part of Application", | |
"inputfield": true, | |
"length": 30, | |
"name": "appln_section", | |
"placeholder": "E.g. claim 1 or page 3 line 34 or paragraph [0050]" | |
}, { | |
"header": "Please Indicate Proposed Amendment", | |
"inputfield": true, | |
"length": 50, | |
"name": "change", | |
"placeholder": "E.g. [old phrase] amended to [new phrase]." | |
}, { | |
"header": "Basis in Application as filed", | |
"inputfield": true, | |
"length": 50, | |
"name": "basis", | |
"placeholder": "Please cite the basis for the amendment." | |
}, { | |
"header": "Reason amendment addresses objection", | |
"inputfield": true, | |
"length": 50, | |
"name": "reason", | |
"placeholder": "Please provide evidence that supports your conclusion." | |
}], | |
"instances": [{ | |
"appln_section": "fhfghfg", | |
"basis": "fghfhfh", | |
"change": "fghhfdh", | |
"childlinks": [], | |
"date_created": "16 November 2016", | |
"date_modified": "16 November 2016", | |
"id": 1, | |
"parent_id": 1, | |
"reason": "fghfghfghdf", | |
"uri": "http://localhost/oar/amendments/data/1" | |
}] | |
} | |
const amendmentdata2 = { | |
"api_uri": "http://localhost/oar/amendments/data/", | |
"fielddata": [{ | |
"header": "Associated Part of Application", | |
"inputfield": true, | |
"length": 30, | |
"name": "appln_section", | |
"placeholder": "E.g. claim 1 or page 3 line 34 or paragraph [0050]" | |
}, { | |
"header": "Please Indicate Proposed Amendment", | |
"inputfield": true, | |
"length": 50, | |
"name": "change", | |
"placeholder": "E.g. [old phrase] amended to [new phrase]." | |
}, { | |
"header": "Basis in Application as filed", | |
"inputfield": true, | |
"length": 50, | |
"name": "basis", | |
"placeholder": "Please cite the basis for the amendment." | |
}, { | |
"header": "Reason amendment addresses objection", | |
"inputfield": true, | |
"length": 50, | |
"name": "reason", | |
"placeholder": "Please provide evidence that supports your conclusion." | |
}], | |
"instances": [] | |
} | |
var tabs = [{ | |
"title": "Enter Objections", | |
"tab": "<Tab1 />" | |
}, { | |
"title": "Analyse Objections", | |
"tab": "<Tab2 />" | |
}, { | |
"title": "Add Amendments", | |
"tab": "<Tab2 />" | |
}, ] | |
/* | |
var DisplayField = DisplayField(ParagraphRender); | |
ReactDOM.render( | |
<DisplayField | |
field={""} onClick={""}/>, | |
document.getElementById("container") | |
); | |
*/ | |
/* | |
var EF = EditField(ParagraphInputTextArea); | |
ReactDOM.render( | |
<EF field={ | |
{ | |
"header": "Reason amendment addresses objection", | |
"inputfield": true, | |
"length": 50, | |
"name": "reason", | |
"placeholder": "Please enter text", | |
"value": "This is a test value" | |
} | |
} | |
sendValueToParent={""} | |
/>, | |
document.getElementById("container") | |
); | |
*/ | |
/* | |
ReactDOM.render( < | |
EditInstance instancedata = { | |
[{ | |
"header": "Reason amendment addresses objection", | |
"inputfield": true, | |
"length": 50, | |
"name": "reason", | |
"placeholder": "Please enter text", | |
"value": "This is a test value" | |
}, { | |
"header": "Other", | |
"inputfield": true, | |
"length": 50, | |
"name": "other", | |
"placeholder": "Please enter text", | |
"value": "This is another value" | |
}] | |
} | |
displayrender = { | |
ParagraphRender | |
} | |
editrender = { | |
ParagraphInputTextArea | |
} | |
/>, | |
document.getElementById("container") | |
); | |
*/ | |
var IC = InstanceContainer(PanelContent) | |
ReactDOM.render( | |
<IC fielddata={objectiondata.fielddata} instance={objectiondata.instances[0]}/>, | |
document.getElementById("container") | |
); |
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
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.1/react.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.1/react-dom.min.js"></script> |
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
.center-block() { | |
display: block; | |
margin-left: auto; | |
margin-right: auto; | |
} | |
.panelleftpane { | |
margin-left: 25px | |
} | |
.panelcontainer { | |
padding: 25px; | |
border-bottom: 1px solid grey; | |
} | |
.panel { | |
padding-bottom: 15px; | |
border-bottom: 0.5px solid lightgrey; | |
} | |
.panelright { | |
.center-block(); | |
} |
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
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment