Trying to get the hang of Mithril, can't really understand one thing. Can I render components on events?
Let's assume I have one parent component:
var MyApp = {
view: function() {
return m("div", [
m.component(MyApp.header, {}),
m("div", {id: "menu-container"})
])
}
};
m.mount(document.body, megogo.main);
It renders the header component (and a placeholder for the menu (do I even need it?)):
MyApp.header = {
view: function() {
return m("div", {
id: 'app-header'
}, [
m('a', {
href: '#',
id: 'menu-button',
onclick: function(){
// this part is just for reference
m.component(MyApp.menu, {})
}
}, 'Menu')
])
}
}
When the user clicks on the menu link I want to load the menu items from my API and only then render the menu.
MyApp.menu = {
controller: function() {
var categories = m.request({method: "GET", url: "https://api.site.com/?params"});
return {categories: categories};
},
view: function(ctrl) {
return m("div", ctrl.categories().data.items.map(function(item) {
return m("a", {
href: "#",
class: 'link-button',
onkeydown: MyApp.menu.keydown
}, item.title)
}));
},
keydown: function(e){
e.preventDefault();
var code = e.keyCode || e.which;
switch(code){
// ...
}
}
};
This part will obviously not work
onclick: function(){
// this part is just for reference
m.component(MyApp.menu, {})
}
So, the question is what is the correct way render components on event?
Try This:
http://jsbin.com/nilesi/3/edit?js,output
You can even toggle the menu.
And remember that you get a promise wrapped in an m.prop from the call to m.request. You'll need to check that it has returned before the menu button can be clicked.
// I'd stick this in a view-model
var showMenu = m.prop(false)
var MyApp = {
view: function(ctrl) {
return m("div", [
m.component(MyApp.header, {}),
showMenu() ? m.component(MyApp.menu) : ''
])
}
};
MyApp.header = {
view: function() {
return m("div", {
id: 'app-header'
}, [
m('a', {
href: '#',
id: 'menu-button',
onclick: function(){
showMenu(!showMenu())
}
}, 'Menu')
])
}
}
MyApp.menu = {
controller: function() {
//var categories = m.request({method: "GET", url: "https://api.site.com/?params"});
var categories = m.prop([{title: 'good'}, {title: 'bad'}, {title: 'ugly'}])
return {categories: categories};
},
view: function(ctrl) {
return m("div.menu", ctrl.categories().map(function(item) {
return m("a", {
href: "#",
class: 'link-button',
onkeydown: MyApp.menu.keydown
}, item.title)
}));
},
keydown: function(e){
e.preventDefault();
var code = e.keyCode || e.which;
switch(code){
// ...
}
}
};
m.mount(document.body, MyApp);
First of all, you'll want to use the return value of m.component, either by returning it from view, or (more likely what you want) put it as a child of another node; use a prop to track whether it's currently open, and set the prop when you wish to open it.
To answer the actual question: by default Mithril will trigger a redraw itself when events like onclick and onkeydown occur, but to trigger a redraw on your own, you'll want to use either m.redraw or m.startComputation / m.endComputation.
The difference between them is that m.redraw will trigger a redraw as soon as it's called, while m.startComputation and m.endComputation will only trigger a redraw once m.endComputation is called the same amount of times that m.startComputation has been called, so that the view isn't redrawn more than once if multiple functions need to trigger a redraw once they've finished.
Related
I am trying do develop my own simple drag and drop functionality for a form builder. I have got a list of items which is rendered in a v-for loop with a computed property. On onDragStart I set the state isDragging. This should alter the list in the computed property and insert some slots where I can drop the dragged item. But immediatly after dragging an item, it triggers the onDragEnd Event.
Initialy i was missing the :key property, but adding it didnt solve the problem. Hopefully someone knows a solution or has an idea whats wrong.
Here is a Link to the code: https://jsfiddle.net/elrihor/vwb3k6qc/49/
Vue.createApp({
data() {
return {
list: [{
id: 'i1',
name: 'Apfel',
},
{
id: 'i2',
name: 'Banane',
},
{
id: 'i3',
name: 'Kirsche',
},
{
id: 'i4',
name: 'Orange',
},
],
props: {
item: {
props: {
draggable: true,
ondragstart: ($event) => {
this.startDrag();
},
ondragend: ($event) => {
this.endDrag();
},
},
},
slot: {
props: {
ondrop: ($event) => {
$event.preventDefault();
this.onDrop($event);
},
ondragover: ($event) => {
$event.preventDefault();
}
},
},
},
isDragging: false
}
},
computed: {
dragList() {
let dragList = this.list;
if (this.isDragging) {
dragList = dragList.reduce((r, a) => r.concat(a, {
type: 'slot',
name: 'Slot'
}), [{
type: 'slot',
name: 'Slot'
}]);
}
dragList = dragList.map((item, index) => {
return {
...item,
id: !item.id ? 's' + index : item.id,
type: item.type == 'slot' ? 'slot' : 'item',
};
});
return dragList;
}
},
methods: {
getProps(type) {
return this.props[type].props;
},
startDrag() {
console.log('start');
this.isDragging = true;
},
endDrag() {
console.log('end');
this.isDragging = false;
},
onDrop(e) {
console.log('drop');
}
}
}).mount('#app')
I think the issue lies in choosing to use the ondragstart event. When this is fired, the DOM is re-rendered to display your slots, which moves things around so that your mouse cursor is no longer over the element itself. dragend is then fired before the drag actually commenced. Change this simply to ondrag and it will work:
https://jsfiddle.net/m7ps3f40/
To debug and figure this out, I simplified your fiddle a lot. I felt the dragList computed and the :bind="getProps" used more code and is less readable than the a simpler approach of just using HTML elements and Vue-style events for dragging. You might find it interesting to take a look:
https://jsfiddle.net/o5pyhtd3/2/
but of course your fiddle might be a cut down version of a bigger piece of code, so perhaps you need to stick to your approach.
I am creating undo/redo functionality in VueJS. I watch the settings and add a new element to an array of changes when the settings change. I also have a method for undo when the undo button is clicked.
However, when the button is clicked and the last setting is reverted, the settings are changed and the watch is fired again.
How can I prevent a new element being added to the array of changes if the settings changed but it was because the Undo button was clicked?
(function () {
var Admin = {};
Admin.init = function () {
};
var appData = {
settings: {
has_border: true,
leave_reviews: true,
has_questions: true
},
mutations: [],
mutationIndex: null,
undoDisabled: true,
redoDisabled: true
};
var app = new Vue({
el: '#app',
data: appData,
methods: {
undo: function() {
if (this.mutations[this.mutationIndex - 1]) {
let settings = JSON.parse(this.mutations[this.mutationIndex - 1]);
this.settings = settings;
this.mutationIndex = this.mutations.length - 1;
console.log (settings);
}
},
redo: function() {
}
},
computed: {
border_class: {
get: function () {
return this.settings.has_border ? ' rp-pwb' : ''
}
},
undo_class: {
get: function () {
return this.undoDisabled ? ' disabled' : ''
}
},
redo_class: {
get: function () {
return this.redoDisabled ? ' disabled' : ''
}
}
},
watch: {
undoDisabled: function () {
return this.mutations.length;
},
redoDisabled: function () {
return this.mutations.length;
},
settings: {
handler: function () {
let mutation = JSON.stringify(this.settings),
prevMutation = JSON.stringify(this.mutations[this.mutations.length-1]);
if (mutation !== prevMutation) {
this.mutations.push(mutation);
this.mutationIndex = this.mutations.length - 1;
this.undoDisabled = false;
}
},
deep: true
}
}
});
Admin.init();
})();
Since you make the changes with a button click, you can create a method to achieve your goal instead of using watchers.
methods: {
settings() {
// call this method from undo and redo methods if the conditions are met.
// move the watcher code here.
}
}
BTW,
If you don't use setter in computed properties, you don't need getters, so that is enough:
border_class() {
return this.settings.has_border ? ' rp-pwb' : ''
},
These watchers codes look belong to computed:
undoDisabled() {
return this.mutations.length;
},
redoDisabled() {
return this.mutations.length;
},
tldr:
User is a global object.
If I change show value, the component will update instantly, that's OK.
What I want to gain is like "When User.isLoggedIn() becomes false, the Log out element must hide. When it becomes true, the element must show and Login/Signup must hide." In my app, this goal would transform into another, "When I'm redirecting from login,signup, orsignout pages, these properties(and state of the button) must be updated."
Toolbar.Vue.
Script:
<script>
export default {
data() {
return {
items: [
{title: 'Questions', to: '/questions', show: true},
{title: 'Ask question', to: '/askQuestion', show: true},
{title: 'Categories', to: '/categories', show: true},
// only F5.
{title: 'Login/Signup', to: '/login', show: !User.isLoggedIn()},
{title: 'Log out', to: '/logout', show: User.isLoggedIn()},
]
}
},
}
Piece of markup:
<router-link
v-for="item in items"
:key="item.title"
:to="item.to"
v-if="item.show"
>
You know, I'm trying to do the 'logout thing' with vue. I have that Toolbar component with router-link to Logout component.
Note: I don't import User class in my component directly, but I do it in my app.js. like this:
import User from './helpers/AppUser';
window.User = User;
So, I think that everybody has access to the right User. Furthermore, this class is just a grouped couple of methods. Main method is retrieve().
In my Logout component there's the code:
beforeMount() {
User.logout();
router.push('questions')
// window.location = '/questions'
}
So, when I go logout, all is fine (I'm returning to questions page), but my Log out button is still here.
User.isLoggedIn() works properly (When I F5 my page, all is fine).
I also mentioned that if I change show value, the component will update instantly, that's OK.
That try also doesn't work:
{title: 'Login/Signup', to: '/login', show: ()=> !this.isLoggedIn},
{title: 'Log out', to: '/logout', show: ()=> this.isLoggedIn},
],
}
},
computed:{
isLoggedIn: function() {
return User.isLoggedIn();
},
My temp solution is using window.location = '/questions' instead of vue-router.
Maybe I need some watchers, or add User in my global Vue... I don't know.
Update: User class.
/**
* Class-helper for managing user login/signup part. Also represents the user.
*/
class AppUser {
constructor() {
this.storageKey = 'appUser';
}
/**
* retrieves user data from localStorage. if none stored, returns null
* #return {object}|{null}
*/
retrieve() {
let data = {};
try {
data = JSON.parse(localStorage.getItem(this.storageKey));
} catch (e) {
console.log(e);
} finally {
// console.log(data)
}
return data;
}
/**
* clears localStorageEntry
*/
clear() {
localStorage.removeItem(this.storageKey);
}
/**
* #return boolean
*/
hasId() {
let data = this.retrieve();
return data === null ? false : data.hasOwnProperty('id');
// if id then return true
}
/**
* #return boolean
*/
hasToken() {
let data = this.retrieve();
return data === null ? false : data.hasOwnProperty('jwt');
// if token then return true
}
isLoggedIn() {
// console.log('in user.isLoggedIn')
// try {
return this.hasToken() && this.hasId();
// }
// catch (e) {
// console.log(e);
// }
}
}
export default AppUser = new AppUser();
You can replace the original method of the User at "created" phase.
return {
data () {
return {
items: [
{ title: 'Questions', to: '/questions', show: true },
{ title: 'Ask question', to: '/askQuestion', show: true },
{ title: 'Categories', to: '/categories', show: true },
{ title: 'Login/Signup', to: '/login', show: !User.isLoggedIn() },
{ title: 'Log out', to: '/logout', show: User.isLoggedIn() },
]
}
},
created () {
let app = this;
let items = app.items;
let loginUser = User.login.bind(User);
let logoutUser = User.logout.bind(User);
User.login = () => {
// modify the data
items[3].show = false;
items[4].show = true;
// do login
loginUser();
};
User.logout = () => {
// modify the data
items[3].show = true;
items[4].show = false;
// do logout
logoutUser();
};
}
};
We're using a tree-style navigation element which needs to allow other directives/controllers to know:
What the current selection is, and
When the row selection changes
I'm trying to determine the best angular-way to handle this.
Until now, we've been firing an event the entire app can listen to - less than ideal but ensures nothing is hard-coded to communicate directly with the component.
However, we now have a need to obtain the current selection when another component is activated. An event won't fly.
So I'm considering a service, some singleton which holds the current selection and can be updated directly by the tree, and read from by anyone who needs it.
However, this present some other issues:
Would it be better to ditch the event entirely, and have components which need to know when it changes $watch the service's nodeId?
If I use $watch, it seems like I should expose the object directly - so using getters/setters won't work unless I want to complicate the needed $watch code?
Part of my concern is that this would allow any component to set the value and this is intentionally not something we'll allow - the change will not impact the tree but will de-sync the service values from the true values, and will fire invalid $watches.
Implementing a getter should not lead to a complicated $watcher:
Service:
angular.service('myService', function() {
var privateVar = 'private';
return {
getter: function() {
return privateVar;
};
});
Controller:
angular.controller('myController', function(myService){
$scope.watch(myService.getter, function(){
//do stuff
};
});
See this plunker: http://plnkr.co/edit/kLDwFg9BtbkdfoSeE7qa?p=preview
I think using a service should work and you don't need any watchers for it.
In my demo below or in this fiddle I've added the following:
One service/factory sharedData that's storing the data - selection and items
Another service for eventing sharedDataEvents with observer/listener pattern.
To display the value in component2 I've used one-way binding, so that component can't change the selection.
Also separating data from events prevents a component from changing the selection. So only MainController and Component1 can change the selection.
If you're opening the browser console you can see the listeners in action. Only listener of component3 is doing something (after 3 selection changes it will do an alert) the others are just logging the new selection to console.
angular.module('demoApp', [])
.controller('MainController', MainController)
.directive('component1', Component1)
.directive('component2', Component2)
.directive('component3', Component3)
.factory('sharedData', SharedData)
.factory('sharedDataEvents', SharedDataEvents);
function MainController(sharedData) {
sharedData.setItems([{
id: 0,
test: 'hello 0'
}, {
id: 1,
test: 'hello 1'
}, {
id: 2,
test: 'hello 2'
}]);
this.items = sharedData.getItems();
this.selection = this.items[0];
}
function Component1() {
return {
restrict: 'E',
scope: {},
bindToController: {
selection: '='
},
template: 'Comp1 selection: {{comp1Ctrl.selection}}'+
'<ul><li ng-repeat="item in comp1Ctrl.items" ng-click="comp1Ctrl.select(item)">{{item}}</li></ul>',
controller: function($scope, sharedData, sharedDataEvents) {
this.items = sharedData.getItems();
this.select = function(item) {
//console.log(item);
this.selection = item
sharedData.setSelection(item);
};
sharedDataEvents.addListener('onSelect', function(selected) {
console.log('selection changed comp. 1 listener callback', selected);
});
},
controllerAs: 'comp1Ctrl'
};
}
function Component2() {
return {
restrict: 'E',
scope: {},
bindToController: {
selection: '#'
},
template: 'Comp2 selection: {{comp2Ctrl.selection}}',
controller: function(sharedDataEvents) {
sharedDataEvents.addListener('onSelect', function(selected) {
console.log('selection changed comp. 2 listener callback', selected);
});
},
controllerAs: 'comp2Ctrl'
};
}
function Component3() {
//only listening and alert on every third change
return {
restrict: 'E',
controller: function($window, sharedDataEvents) {
var count = 0;
sharedDataEvents.addListener('onSelect', function(selected, old) {
console.log('selection changed comp. 3 listener callback', selected, old);
if (++count === 3) {
count = 0;
$window.alert('changed selection 3 times!!! Detected by Component 3');
}
});
}
}
}
function SharedData(sharedDataEvents) {
return {
selection: {},
items: [],
setItems: function(items) {
this.items = items
},
setSelection: function(item) {
this.selection = item;
sharedDataEvents.onSelectionChange(item);
},
getItems: function() {
return this.items;
}
};
}
function SharedDataEvents() {
return {
changeListeners: {
onSelect: []
},
addListener: function(type, cb) {
this.changeListeners[type].push({ cb: cb });
},
onSelectionChange: function(selection) {
console.log(selection);
var changeEvents = this.changeListeners['onSelect'];
console.log(changeEvents);
if ( ! changeEvents.length ) return;
angular.forEach(changeEvents, function(cbObj) {
console.log(typeof cbObj.cb);
if (typeof cbObj.cb == 'function') {
// callback is a function
if ( selection !== cbObj.previous ) { // only trigger if changed
cbObj.cb.call(null, selection, cbObj.previous);
cbObj.previous = selection; // new to old for next run
}
}
});
}
};
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.7/angular.js"></script>
<div ng-app="demoApp" ng-controller="MainController as ctrl">
<p>Click on a list item to change selection:</p>
<component1 selection="ctrl.selection"></component1> <!-- can change the selection -->
<component2 selection="{{ctrl.selection}}"></component2>
<component3></component3>
</div>
I have object container <div id="dObjectContainer" class="dObjectContainer">
and a widget TreeObject.
$.widget('qs.TreeObject2', {
options: {
oID: '0',
IMGUrl: '../../Content/Images/noIMG.jpg',
Name: 'Name',
IsActive: true,
IsVisible: true
},
_create: function () {
var self = this,
o = self.options,
el = self.element;
el.attr("oID", o.oID);
el.addClass("dObject");
el.addClass("ui-widget-content");
var H1 = $("<h1></h1>").text(o.Name);
el.append(H1);
var img = $("<img></img>");
img.attr("src", o.IMGUrl);
img.attr("alt", o.Name);
el.append(img);
},
DoHide: function (strName, bActive) {
var self = this,
o = self.options;
if (((o.Name.toUpperCase().indexOf(strName.toUpperCase()) > -1) || (strName == "")) && ((o.IsActive) || (bActive == false))) {
if (!o.IsVisible) {
this.element.show();
o.IsVisible = true;
}
}
else {
if (o.IsVisible) {
this.element.hide();
o.IsVisible = false;
}
}
},
destroy: function () {
$.Widget.prototype.destroy.call(this);
}
});
I want to add multiple objects from javascript something like this:
$('#dObjectContainer').append($("<div></div>").TreeObject2({ oID: "1", IMGUrl: '../../Content/Images/StoredImages/Categories/images.jpg', Name: "n1", IsActive: true }));
$('#dObjectContainer').append($("<div></div>").TreeObject2({ oID: "2", Name: "n2", IsActive: false }));
This approach adds two elemets of each object instead of one. - Found reason - for some unknown reson ASP.MVC 3 razor view calls (function ($) {} (jQuery)); twice. First time it calls only view document.ready - second time layoutpage and view document ready.
For other part - how to access their metods:
$('.dObject').TreeObject2("DoHide", strName, bActive);
This works in case there is no more elements having class dObject.