I have a react component that is designed to show a button whenever you are within a certain time limit of when something was created.
var UndoButton = React.createClass({
getDefaultProps: function() {
return ({
timeWindow: 900
})
},
getInitialState: function() {
return ({
commentAge: this.props.timeWindow + 1
})
},
determineDeletable: function() {
this.setState({
commentAge: this.commentAge()
});
},
componentDidMount: function() {
this.determineDeletable()
this.timer = setInterval(this.determineDeletable, 1000);
},
componentWillUnMount: function() {
clearTimer(this.timer);
},
commentAge: function() {
return Math.floor(new Date().getTime() / 1000) - this.props.created_at;
},
inPeriod: function() {
return this.timeRemaining() > 0;
},
timeRemaining: function() {
return this.props.timeWindow - this.state.commentAge;
},
render: function() {
if (!this.inPeriod()) { return false }
return (
<a href='#' className='btn btn-xs btn-default'>Undo Comment ({this.timeRemaining()}s)</a>
)
}
});
Right now, this renders the button within the time window perfectly. However, even though the state is updating every second, the render function does not appear to be running as the time remaining count does not change. Also, when the time window is hit the button doesn't dissapear.
So, what's blocking this React component from rendering the correct information?
Related
Scenario:
I’m developing a Vue scroll component that wraps around a dynamic number of HTML sections and then dynamically builds out vertical page navigation allowing the user to scroll or jump to page locations onScroll.
Detail:
a. In my example my scroll component wraps 3 sections. All section id’s start with "js-page-section-{{index}}"
b. The objective is to get the list of section nodes (above) and then dynamically build out vertical page (nav) navigation based on the n number of nodes found in the query matching selector criteria. Therefore, three sections will result in three page section navigation items. All side navigation start with “js-side-nav-{{index}}>".
c. Once the side navigation is rendered I need to query all the navigation nodes in order to control classes, heights, display, opacity, etc. i.e document.querySelectorAll('*[id^="js-side-nav"]');
EDIT
Based on some research here are the options for my problem. Again my problem being 3 phase DOM state management i.e. STEP 1. Read all nodes equal to x, then STEP 2. Build Side Nav scroll based on n number of nodes in document, and then STEP 3. Read all nav nodes to sync with scroll of document nodes:
Create some sort of event system is $emit() && $on. In my opinion this gets messy very quickly and feels like a poor solution. I found myself quickly jumping to $root
Vuex. but that feels like an overkill
sync. Works but really that is for parent child property state management but that again requires $emit() && $on.
Promise. based service class. This seems like the right solution, but frankly it became a bit of pain managing multiple promises.
I attempted to use Vue $ref but frankly it seems better for managing state rather than multi stage DOM manipulation where a observer event approach is better.
The solution that seems to work is Vues $nextTick(). which seems to be similar to AngularJS $digest. In essence it is a . setTimeout(). type approach just pausing for next digest cycle. That said there is the scenario where the tick doesn’t sync the time requires so I built a throttle method. Below is the code update for what is worth.
The refactored watch with nextTick()
watch: {
'page.sections': {
handler(nodeList, oldNodeList){
if (this.isNodeList(nodeList) && _.size(nodeList) && this.sideNavActive) {
return this.$nextTick(this.sideNavInit);
}
},
deep: true
},
},
The REFACTORED Vue component
<template>
<div v-scroll="handleScroll">
<nav class="nav__wrapper" id="navbar-example">
<ul class="nav">
<li role="presentation"
:id="sideNavPrefix + '-' + (index + 1)"
v-for="(item, key,index) in page.sections">
<a :href="'#' + getAttribute(item,'id')">
<p class="nav__counter" v-text="('0' + (index + 1))"></p>
<h3 class="nav__title" v-text="getAttribute(item,'data-title')"></h3>
<p class="nav__body" v-text="getAttribute(item,'data-body')"></p>
</a>
</li>
</ul>
</nav>
<slot></slot>
</div>
</template>
<script>
import ScrollPageService from '../services/ScrollPageService.js';
const _S = "section", _N = "sidenavs";
export default {
name: "ScrollSection",
props: {
nodeId: {
type: String,
required: true
},
sideNavActive: {
type: Boolean,
default: true,
required: false
},
sideNavPrefix: {
type: String,
default: "js-side-nav",
required: false
},
sideNavClass: {
type: String,
default: "active",
required: false
},
sectionClass: {
type: String,
default: "inview",
required: false
}
},
directives: {
scroll: {
inserted: function (el, binding, vnode) {
let f = function(evt) {
if (binding.value(evt, el)) {
window.removeEventListener('scroll', f);
}
};
window.addEventListener('scroll', f);
}
},
},
data: function () {
return {
scrollService: {},
page: {
sections: {},
sidenavs: {}
}
}
},
methods: {
getAttribute: function(element, key) {
return element.getAttribute(key);
},
updateViewPort: function() {
if (this.scrollService.isInCurrent(window.scrollY)) return;
[this.page.sections, this.page.sidenavs] = this.scrollService.updateNodeList(window.scrollY);
},
handleScroll: function(evt, el) {
if ( !(this.isScrollInstance()) ) {
return this.$nextTick(this.inViewportInit);
}
this.updateViewPort();
},
getNodeList: function(key) {
this.page[key] = this.scrollService.getNodeList(key);
},
isScrollInstance: function() {
return this.scrollService instanceof ScrollPageService;
},
sideNavInit: function() {
if (this.isScrollInstance() && this.scrollService.navInit(this.sideNavPrefix, this.sideNavClass)) this.getNodeList(_N);
},
inViewportInit: function() {
if (!(this.isScrollInstance()) && ((this.scrollService = new ScrollPageService(this.nodeId, this.sectionClass)) instanceof ScrollPageService)) this.getNodeList(_S);
},
isNodeList: function(nodes) {
return NodeList.prototype.isPrototypeOf(nodes);
},
},
watch: {
'page.sections': {
handler(nodeList, oldNodeList){
if (this.isNodeList(nodeList) && _.size(nodeList) && this.sideNavActive) {
return this.$nextTick(this.sideNavInit);
}
},
deep: true
},
},
mounted() {
return this.$nextTick(this.inViewportInit);
},
}
</script>
END EDIT
ORIGINAL POST
Problem & Question:
PROBLEM:
The query of sections and render of navs work fine. However, querying the nav elements fails as the DOM has not completed the render. Therefore, I’m forced to use a setTimeout() function. Even if I use a watch I’m still forced to use timeout.
QUESTION:
Is there a promise or observer in Vue or JS I can use to check to see when the DOM has finished rendering the nav elements so that I can then read them? Example in AngularJS we might use $observe
HTML EXAMPLE
<html>
<head></head>
<body>
<scroll-section>
<div id="js-page-section-1"
data-title="One"
data-body="One Body">
</div>
<div id="js-page-section-2"
data-title="Two"
data-body="Two Body">
</div>
<div id="js-page-section-3"
data-title="Three"
data-body="THree Body">
</div>
</scroll-section>
</body>
</html>
Vue Compenent
<template>
<div v-scroll="handleScroll">
<nav class="nav__wrapper" id="navbar-example">
<ul class="nav">
<li role="presentation"
:id="[idOfSideNav(key)]"
v-for="(item, key,index) in page.sections.items">
<a :href="getId(item)">
<p class="nav__counter">{{key}}</p>
<h3 class="nav__title" v-text="item.getAttribute('data-title')"></h3>
<p class="nav__body" v-text="item.getAttribute('data-body')"></p>
</a>
</li>
</ul>
</nav>
<slot></slot>
</div>
</template>
<script>
export default {
name: "ScrollSection",
directives: {
scroll: {
inserted: function (el, binding, vnode) {
let f = function(evt) {
_.forEach(vnode.context.page.sections.items, function (elem,k) {
if (window.scrollY >= elem.offsetTop && window.scrollY <= (elem.offsetTop + elem.offsetHeight)) {
if (!vnode.context.page.sections.items[k].classList.contains("in-viewport") ) {
vnode.context.page.sections.items[k].classList.add("in-viewport");
}
if (!vnode.context.page.sidenavs.items[k].classList.contains("active") ) {
vnode.context.page.sidenavs.items[k].classList.add("active");
}
} else {
if (elem.classList.contains("in-viewport") ) {
elem.classList.remove("in-viewport");
}
vnode.context.page.sidenavs.items[k].classList.remove("active");
}
});
if (binding.value(evt, el)) {
window.removeEventListener('scroll', f);
}
};
window.addEventListener('scroll', f);
},
},
},
data: function () {
return {
page: {
sections: {},
sidenavs: {}
}
}
},
methods: {
handleScroll: function(evt, el) {
// Remove for brevity
},
idOfSideNav: function(key) {
return "js-side-nav-" + (key+1);
},
classOfSideNav: function(key) {
if (key==="0") {return "active"}
},
elementsOfSideNav:function() {
this.page.sidenavs = document.querySelectorAll('*[id^="js-side-nav"]');
},
elementsOfSections:function() {
this.page.sections = document.querySelectorAll('*[id^="page-section"]');
},
},
watch: {
'page.sections': function (val) {
if (_.has(val,'items') && _.size(val.items)) {
var self = this;
setTimeout(function(){
self.elementsOfSideNavs();
}, 300);
}
}
},
mounted() {
this.elementsOfSections();
},
}
</script>
I hope I can help you with what I'm going to post here. A friend of mine developed a function that we use in several places, and reading your question reminded me of it.
"Is there a promise or observer in Vue or JS I can use to check to see when the DOM has finished rendering the nav elements so that I can then read them?"
I thought about this function (source), here below. It takes a function (observe) and tries to satisfy it a number of times.
I believe you can use it at some point in component creation or page initialization; I admit that I didn't understand your scenario very well. However, some points of your question immediately made me think about this functionality. "...wait for something to happen and then make something else happen."
<> Credits to #Markkop the creator of that snippet/func =)
/**
* Waits for object existence using a function to retrieve its value.
*
* #param { function() : T } getValueFunction
* #param { number } [maxTries=10] - Number of tries before the error catch.
* #param { number } [timeInterval=200] - Time interval between the requests in milis.
* #returns { Promise.<T> } Promise of the checked value.
*/
export function waitForExistence(getValueFunction, maxTries = 10, timeInterval = 200) {
return new Promise((resolve, reject) => {
let tries = 0
const interval = setInterval(() => {
tries += 1
const value = getValueFunction()
if (value) {
clearInterval(interval)
return resolve(value)
}
if (tries >= maxTries) {
clearInterval(interval)
return reject(new Error(`Could not find any value using ${tries} tentatives`))
}
}, timeInterval)
})
}
Example
function getPotatoElement () {
return window.document.querySelector('#potato-scroller')
}
function hasPotatoElement () {
return Boolean(getPotatoElement())
}
// when something load
window.document.addEventListener('load', async () => {
// we try sometimes to check if our element exists
const has = await waitForExistence(hasPotatoElement)
if (has) {
// and if it exists, we do this
doThingThatNeedPotato()
}
// or you could use a promise chain
waitForExistence(hasPotatoElement)
.then(returnFromWaitedFunction => { /* hasPotatoElement */
if (has) {
doThingThatNeedPotato(getPotatoElement())
}
})
})
I am making a get request to a quiz api. When the user gets the answer correct the next answer is shown.
This is all working well, however I have got into some trouble when trying to clear the input box when the user gets the answer correct. I read this earlier and as far as I can tell it should be following the same logic.
Can anyone spot what is wrong here?
var Quiz = React.createClass({
getInitialState: function() {
return {
question: '',
answer: '',
value: '',
score: 0
}
},
getData: function() {
$.get('http://jservice.io/api/random', function(data){
var response = data[0];
console.log(response)
this.setState({
question: response.question,
answer: response.answer
})
}.bind(this));
},
componentDidMount: function() {
this.serverRequest = this.getData()
},
checkAnswer: function(event) {
if(event.target.value.toLowerCase() === this.state.answer.toLowerCase()) {
this.setState({
score: this.state.score + 1,
value: ''
})
this.getData();
}
},
skipQuestion: function() {
this.getData();
},
render: function() {
var value = this.state.value
return (
<div>
<p>{this.state.question}</p>
<input type='text' value={value} onChange={this.checkAnswer}/>
<p onClick={this.skipQuestion}>New question</p>
<p>{this.state.score}</p>
</div>
)
}
});
I moved this code into a jsbin and your input clearing logic is working fine. However, as #finalfreq mentioned in your implementation it's impossible to type a full answer in to the input box, each input gets recognized but is never displayed. The fix for that is shown below. The only change is adding the else case in checkAnswer:
var Quiz = React.createClass({
getInitialState: function() {
return {
question: '',
answer: '',
value: '',
score: 0
}
},
getData: function() {
$.get('http://jservice.io/api/random', function(data){
var response = data[0];
console.log(response)
this.setState({
question: response.question,
answer: response.answer
})
}.bind(this));
},
componentDidMount: function() {
this.serverRequest = this.getData()
},
checkAnswer: function(event) {
if(event.target.value.toLowerCase() === this.state.answer.toLowerCase()) {
this.setState({
score: this.state.score + 1,
value: ''
})
this.getData();
} else {
this.setState({
value: event.target.value.toLowerCase()
})
}
},
skipQuestion: function() {
this.getData();
},
render: function() {
var value = this.state.value
return (
<div>
<p>{this.state.question}</p>
<input type='text' value={value} onChange={this.checkAnswer}/>
<p onClick={this.skipQuestion}>New question</p>
<p>{this.state.score}</p>
</div>
)
}
});
When I click the button that triggers subscribe() I'm trying to change this.state.subscribe to be the opposite boolean of what it is. I'm not even managing change the value of this.state.subscribed much less render different text when clicking the button. I've tried replaceState and adding call back function. Not exactly sure what I should be putting in the call back function though, if that's what I'm doing wrong.
SingleSubRedditList = React.createClass({
mixins: [ReactMeteorData],
getMeteorData: function(){
Meteor.subscribe('posts');
var handle = Meteor.subscribe('profiles');
return {
loading: !handle.ready(),
posts: Posts.find().fetch(),
profiles: Profiles.find({}).fetch()
};
},
getInitialState: function(){
var subscribed;
return {
subscribed: subscribed
};
},
populateButton: function(){
//fetch is cumbersome, later try and do this another way to make it faster
var profile = Profiles.find({_id: Meteor.userId()}).fetch();
if (profile.length > 0){
this.state.subscribed = profile[0].subreddits.includes(this.props.subreddit);
}
},
subscribe: function(){
this.setState({
subscribed: ! this.state.subscribed
}
);
},
renderData: function(){
return this.data.posts.map((post) => {
return <SinglePost title={post.title} content={post.content} key={post._id} id={post._id} />
});
},
render: function(){
this.populateButton()
return (
<div>
<button onClick={this.subscribe}>
<p>{this.state.subscribed ? 'Subscribed' : 'Click To Subscribe'}</p>
</button>
<p></p>
<ul>
{this.renderData()}
</ul>
</div>
);
}
});
You can't set the state using this.state.subscribed = ..., which you are currently doing in populateButton. Trying to directly mutate the state like that will cause it to behave strangely.
You are also initializing subscribed with an undefined variable. You should give it an initial status of true or false.
I am new to Backbone and have an issue from almous beginning wondering how to overcome issue with unexpected behavior of a main view.
1. I launch page and it looks okey.
2. I click button that lead me to different view and shows me it well.
3. I click "back" button and I see a blank page, but in DOM I can find some elements from previous view, but not visible.
Here is a piece of my router code (I hope this pieces will be enough):
home: function () {
if (!app.leftMenuView) {
app.leftMenuView = new app.views.LeftMenuView({
el: $("#left_menu")
});
} else {
app.leftMenuView.delegateEvents();
}
if (!app.homeView) {
app.homeView = new app.views.HomeView({
el: $("#main_container")
});
} else {
app.homeView.delegateEvents();
}
if (!app.topMenuView) {
app.topMenuView = new app.views.topMenuView({
el: $("#top_menu")
});
} else {
app.topMenuView.delegateEvents();
}
},
search: function () {
app.searchView = new app.views.SearchView({
el: $("body")
});
},
A piece of main html file:
<body>
<div id="search-div"></div>
...
</body>
HomeView:
app.views.HomeView = Backbone.View.extend({
initialize: function () {
this.render()
},
render: function () {
var self = this;
$.get('/template/home.html', function (data) {
self.$el.html(_.template(data)({}));
});
return this;
},
});
A SearchView:
app.views.SearchView = Backbone.View.extend({
events: {
"click #left-arrow-icon": "toMainPage"
},
initialize: function () {
this.render();
},
render: function () {
var self = this;
$.get('/template/searchView.html', function (data) {
self.$el.html(_.template(data));
});
return this;
},
toMainPage: function () {
Backbone.history.history.back();
},
});
In the example provided, the search view, if rendered, will destroy the main html file content since the element provided is directly the body. In any case, if you go to search view, your home page will have been destroyed or will not be able to render anymore.
I would suggest an improved layout management :
separate elements per views
show/hide of root elements based on routes
I've just started foundational work for a Backbone JS SPA (single page application). I'm using the basic Underscore templating support, and am having issues with unexpected routing occurring.
Basically, the sign up view is shown initially as expected, POSTs succesfully when I click a button and I have it navigate to a simple test view. However, the test view is quickly rendered and then I get re-routed to the default sign up view again.
I see the history of the test page and if I hit the back button I go back to that test view which works fine. I see there is some event being triggered in Backbone which is routing me back to the blank fragment (sign up page), but I have no idea why. I've tried messing with the replace and trigger options on the navigate call with no luck.
As a side note, the start() and stop() View functions were adapted from this article: http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/ . I tried removing this and it had no effect.
$(document).ready( function(){
Backbone.View.prototype.start = function() {
console.debug('starting');
if (this.model && this.modelEvents) {
_.each(this.modelEvents,
function(functionName, event) {
this.model.bind(event, this[functionName], this);
}, this);
}
console.debug('started');
return this;
};
Backbone.View.prototype.stop = function() {
console.debug('stopping');
if (this.model && this.modelEvents) {
_.each(this.modelEvents,
function(functionName, event) {
this.model.unbind(event, this[functionName]);
}, this);
}
console.debug('stopped');
return this;
};
var myApp = {};
myApp.SignUp = Backbone.Model.extend({
urlRoot:"rest/v1/user",
defaults: {
emailAddress: "email#me.com",
firstName: "First Name",
lastName: "Last Name",
password: "",
confirmPassword: ""
}
});
myApp.SignUpView = Backbone.View.extend({
el: $('#bodyTarget'),
modelEvents: {
'change' : 'render'
},
render: function(){
document.title = "Sign Up Page";
// Compile the template using underscore
var template = _.template( $("#signUpTemplate").html(), this.model.toJSON());
// Load the compiled HTML into the Backbone "el"
this.$el.html( template );
return this;
},
events: {
"click .signUpButton": "signUp"
},
signUp: function() {
bindFormValues(this);
this.model.save(this.model.attributes, {error: this.signUpFailure});
myApp.router.navigate("test", {trigger: true});
},
signUpFailure: function(model, response) {
alert("Failure: " + response);
}
});
myApp.TestView = Backbone.View.extend({
el: $('#bodyTarget'),
modelEvents: {
'change' : 'render'
},
render: function() {
document.title = "Test Page";
this.$el.html( "<div>this is a test view</div>");
return this;
}
});
// for now, just pull values from form back into model
function bindFormValues(view) {
var mod = view.model;
var el = view.$el;
var updates = {};
for (var prop in mod.attributes) {
var found = el.find('* [name="' + prop + '"]');
if (found.length > 0) {
updates[prop] = found.val();
}
}
mod.set(updates/*, {error: errorHandler}*/);
}
// routers
myApp.Router = Backbone.Router.extend({
routes: {
'test': 'test',
'': 'home',
},
home: function() {
console.debug('home:enter');
this.signUpView = new myApp.SignUpView({model: new myApp.SignUp()});
this.showView(this.signUpView);
console.debug('home:exit');
},
test: function() {
console.debug('test:enter');
this.testView = new myApp.TestView({model: new myApp.SignUp()});
this.showView(this.testView);
console.debug('test:exit');
},
showView: function(view) {
if (this.currentView) {
this.currentView.stop();
}
this.currentView = view.start().render();
}
});
myApp.router = new myApp.Router();
Backbone.history.start();
});
My HTML page just brings in the relevant scripts and has the div element bodyTarget which is injected with the views when it loads.
Edit: Duh, I found the problem. It turns out I needed to prevent event propagation on the call to signUp() by returning false.