Building a webclient module, part 2
In which we build access control into the web client.
In the server module tutorial we built one AclOwnerEntity
model, being Review. The web
client was created in the webclient tutorial, without the review part. This part of
the webclient tutorial covers the client-side part of working with access control lists.
For the sake of this tutorial, we made it possible for reviews to be shared with your fellow users, or being hidden since nobody needs to know about your guilty pleasures. :-)
We will be creating a modal in which to add or edit reviews. Furthermore, there will be a window, in which shared reviews are displayed. In order to be a bit flashy, we borrow some code from the comments module and display the reviews in a somewhat playful manner.
Add the Review model to the store
The first step is to make sure that reviews can be retrieved and managed through the store. In Module.js
we add
a new entity named Review:
entities: [
// Rest of the entities
{
name: "Review",
relations: {
creator: {store: "User", fk:"createdBy"},
modifier: {store: "User", fk: "modifiedBy"}
}
}
],
Please note that a useful relation would be the album relation. After all, a review is connected to one album. Since we defined album as a property of artist, you will not be able to retrieve this relation from the store.
Artist Detail Panel
Both modals should be called from the ArtistDetail.js
file (which renders the artist detail panel) and there
should be a way to distinguish between albums with or without reviews.
In order to achieve this, we update the InitComponent
like this:
initComponent: function () {
this.tbar = this.initToolbar();
// Render a 'new review' modal
this.addReviewModal = function (v) {
var dlg = new go.modules.tutorial.music.ReviewDialog();
dlg.setValues({albumId:v.id});
dlg.show();
};
// Render all reviews for the current album in a window (which is offered as a modal)
this.showReviewsModal = function(v) {
var dlg = new go.modules.tutorial.music.ReviewsModal();
dlg.store.setFilter('albumId', {albumId: v.id});
dlg.store.load();
dlg.show();
};
Ext.apply(this, {
// all items are updated automatically if they have a "tpl" (Ext.XTemplate) property or an "onLoad" function. The panel is passed as argument.
items: [
//Artist name component
{
cls: 'content',
xtype: 'box',
tpl: '<h3>{name}</h3>'
},
//Render the avatar
{
xtype: "box",
cls: "content",
tpl: new Ext.XTemplate('<div class="go-detail-view-avatar">\
<div class="avatar" style="{[this.getStyle(values.photo)]}"></div></div>',
{
getStyle: function (photoBlobId) {
return photoBlobId ? 'background-image: url(' + go.Jmap.downloadUrl(photoBlobId) + ')"' : "";
}
})
},
// Albums component, render number of reviews
{
collapsible: true,
title: t("Albums"),
xtype: "panel",
listeners: {
scope: this,
afterrender: function(box) {
box.getEl().on('click', function(e){
//don't execute when user selects text
if(window.getSelection().toString().length > 0) {
return;
}
var container = box.getEl().dom.childNodes[1],
item = e.getTarget("a", box.getEl()),
i = Array.prototype.indexOf.call(container.getElementsByTagName("a"), item);
if(i >=0) {
var album = go.util.Object.convertMapToArray(this.data.albums,'id')[i];
if(album.reviews.length > 0) {
this.showReviewsModal(album);
} else {
this.addReviewModal(album);
}
}
}, this);
}
},
tpl: new Ext.XTemplate('<div class="icons">\
<tpl for="go.util.Object.values(values.albums)">\
<p class="s6"><tpl if="xindex == 1"><i class="icon label">album</i></tpl>\
<span>{name}</span>\
<label>{[go.util.Format.date(values.releaseDate)]} - <tpl for="genre"> {name} </tpl> {[this.displayNumReviews(values.reviews)]}</label>\
</p>\
</tpl>\
</div>',
{
displayNumReviews: function(v){
v = v || null;
if(v === null) {
return "";
} else if(v.length == 0) {
return "- <a class='normal-link'>" + t("Write a Review")+ "</a>";
} else {
return "- <a class='normal-link'>" +v.length+" " + t("Reviews")+ "</a>"
}
}
})
}
]
});
go.modules.tutorial.music.ArtistDetail.superclass.initComponent.call(this);
this.addCustomFields();
},
Study the code. The following things were added:
A link was added to the album overview items. Dependent on the number of known reviews, the link text is altered
If there are no reviews, a Review modal will be opened in which you can add a new review
Otherwise, a window is opened which displays current reviews.
Review modal
The next step is adding a modal, in which to enter a review. Please note that an albumId
is supplied from the
addReviewModal
method:
dlg.setValues({albumId:v.id});
Create a new javascript file, name it ReviewDialog.js
and enter the following code:
go.modules.tutorial.music.ReviewDialog = Ext.extend(go.form.Dialog, {
stateId: 'album-review',
title: t("Review"),
entityStore: "Review",
width: dp(800),
height: dp(600),
maximizable: false,
collapsible: false,
modal: true,
initFormItems: function () {
this.addPanel(new go.permissions.SharePanel());
var items = [{
xtype: 'fieldset',
anchor: "100% 100%",
items: [{
xtype: 'textfield',
name: 'title',
fieldLabel: t("Title"),
anchor: '100%',
allowBlank: false
},
{
xtype: 'radiogroup',
fieldLabel: t("Rating"),
name: "rating",
value: null,
items: [
{boxLabel: t("It stinks"), inputValue: 1},
{boxLabel: t("Meh"), inputValue: 2},
{boxLabel: t("It's OK"), inputValue: 3},
{boxLabel: t("It's pretty good"), inputValue: 4},
{boxLabel: t("A stroke of genius"), inputValue: 5}
]
},
{
xtype: 'xhtmleditor',
name: 'body',
fieldLabel: "",
hideLabel: true,
anchor: '0 -90',
allowBlank: false,
listeners: {
scope: this,
ctrlenter: function() {
this.submit();
}
}
}]
}
];
return items;
},
onLoad : function(entityValues) {
this.supr().onLoad.call(this, entityValues);
}
});
…and add the line:
ReviewDialog.js
to the bottom of your scripts.txt
file.
The code is pretty straightforward, but please note a few things:
The
albumId
field does not need to be defined, since thealbumId
value is already passed from theartistDetail
panel;Permission management is added by the following line:
this.addPanel(new go.permissions.SharePanel());
That’s it. We can now add our own review to the selected album:
Reviews screen
In the reviews screen, a number of things need to be checked:
A user may enter only one review for a certain album.
It must be possible to read, edit or delete other reviews, depending on the ACL settings entered by the creator.
Upon adding or deleting a review, the Artist store is to be reloaded. Please note that we have already implemented in the Review model.
A new javascript file is to be created and added to the scripts.txt
file. We name it ReviewsModal.js
.
go.modules.tutorial.music.ReviewsModal = Ext.extend(go.Window, {
stateId: 'album-reviews',
title: t("Reviews"),
width: dp(1000),
height: dp(800),
maximizable: true,
collapsible: false,
modal: true,
stateful: true,
layout: 'fit',
initComponent: function () {
this.tools = [{
id: "add",
handler: function () {
var dlg = new go.modules.tutorial.music.ReviewDialog();
dlg.setValues({albumId: this.albumid});
dlg.show();
}
}];
this.store = new go.data.Store({
fields: [
'id',
'title',
'body',
'rating',
'albumTitle',
'createdBy',
{name: 'creator', type: "relation"},
'albumId', 'aclId', "permissionLevel"
],
entityStore: "Review"
});
// Use a Group Office store that is connected with an go.data.EntityStore for automatic updates.
this.store.on('load', function (store, records, options) {
this.updateView();
this.updateTitle();
this.toggleAddBtn();
}, this);
this.store.on('remove', function () {
this.updateView();
this.toggleAddBtn();
}, this);
this.on('destroy', function () {
this.store.destroy();
}, this);
this.on("expand", function () {
this.updateView();
}, this);
// Add a simple context menu. Make sure that the correct permissions are set
this.contextMenu = new Ext.menu.Menu({
items: [{
iconCls: 'ic-delete',
text: t("Delete"),
handler: function () {
Ext.MessageBox.confirm(t("Confirm delete"), t("Are you sure you want to delete this item?"), function (btn) {
if (btn !== "yes") {
return;
}
go.Db.store("Review").set({destroy: [this.contextMenu.record.id]});
}, this);
},
scope: this
}, {
iconCls: 'ic-edit',
text: t("Edit"),
handler: function () {
var dlg = new go.modules.tutorial.music.ReviewDialog();
dlg.load(this.contextMenu.record.id).show();
},
scope: this
}]
});
var cntrClass = Ext.extend(Ext.Container, {
initComponent: function () {
Ext.Container.superclass.initComponent.call(this);
Ext.applyIf(this, go.panels.ScrollLoader);
this.initScrollLoader();
},
store: this.store,
scrollUp: true
});
this.items = [
this.commentsContainer = new cntrClass({
region: 'center',
autoScroll: true
})
];
go.modules.tutorial.music.ReviewsModal.superclass.initComponent.call(this);
},
updateView: function () {
this.commentsContainer.removeAll();
this.store.each(function (r) {
var mineCls = r.get("createdBy") == go.User.id ? 'mine' : '';
var readMore = new go.detail.ReadMore({
cls: mineCls
});
var creator = r.get("creator");
if (!creator) {
creator = {
displayName: t("Unknown user")
};
}
var avatar = {
xtype: 'box',
autoEl: {
tag: 'span', 'ext:qtip': t('{author} wrote: ')
.replace('{author}', creator.displayName)
},
cls: 'photo ' + mineCls
};
if (creator.avatarId) {
avatar.style = 'background-image: url(' + go.Jmap.thumbUrl(creator.avatarId, {
w: 40,
h: 40,
zc: 1
}) + ');background-color: transparent;';
} else {
avatar.html = go.util.initials(creator.displayName);
avatar.style = 'background-image: none';
}
readMore.setText(this.getReviewText(r));
this.commentsContainer.add({
xtype: "container",
cls: 'go-messages',
items: [{
xtype: 'container',
label: t("Creator"),
items: [avatar, readMore]
}]
});
// Add a context menu, make permissions dependent on ACL
readMore.on('render', function (me) {
me.getEl().on("contextmenu", function (e, target, obj) {
e.stopEvent();
if (r.data.permissionLevel >= go.permissionLevels.write) {
this.contextMenu.record = r;
this.contextMenu.showAt(e.xy);
}
}, this);
}, this);
}, this);
this.doLayout();
var height = 7; // padding on composer
this.commentsContainer.items.each(function (item, i) {
height += item.getOuterSize().height;
});
},
// Update window title by adding the album title
updateTitle: function () {
var r = this.store.getAt(0), title = this.title;
if (typeof (r) !== "undefined") {
this.setTitle(t("Reviews")+" " + t('for') + " " +
Ext.util.Format.htmlEncode(r.get('albumTitle')));
} else {
this.setTitle(t("Reviews"));
}
},
// Check whether current user had added a review. If they have, hide the add button.
toggleAddBtn: function () {
if (this.store.query('createdBy', go.User.id).getCount() > 0) {
this.tools.add.hide();
} else {
var r = this.store.getAt(0);
if (typeof (r) !== "undefined") {
this.tools.add.albumid = r.get("albumId");
}
}
},
// Render the review text in a nice fashion
getReviewText: function (r) {
var s = "<h4>" + r.get("title") + " </h4><div style='font-size=12px;'>";
for (var ii = 1; ii <= 5; ii++) {
s += "<i class='icon'>star" + (r.get('rating') < ii ? "_border" : "") + "</i>";
}
s += "</div><p class='s6'>" + Ext.util.Format.htmlDecode(r.get('body')) + "</p>";
return s;
}
});
In this file, a number of things happen:
The modal is based on the (relatively empty)
go.Window
class. A number of sane default settings is preconfigured.In the initComponent function, a new store is defined. Please note that the
aclId
andpermissionLevel
fields are being retrieved. We will need these later.A context menu is added.
Each review is being rendered in a container class. We borrowed the layout from the ‘comments’ module to make it look nice
For each review, the permission level is matched with the user’s permissions. If applicable, the user can use the context menu.
The End
This concludes our webclient tutorial. There is one thing left to say: have fun coding your own Group-Office modules!