This document provides Vue.js specific guidelines for developing Countly Server frontend components.
- Component Architecture
- Naming Conventions
- Template Best Practices
- Template System
- State Management
- Vuex Modules
- Backbone Integration
- Security
- Testing with Data Test IDs
Use countlyVue.views.create() for new components:
var MyComponent = countlyVue.views.create({
template: countlyVue.T("/myplugin/templates/mycomponent.html"),
mixins: [
countlyVue.mixins.auth(FEATURE_NAME), // Authorization mixin
countlyVue.mixins.hasDrawers("main") // If using drawers
],
props: {
itemId: { type: String, required: true }
},
data: function() {
return {
isLoading: false,
items: []
};
},
computed: {
// Prefer computed over data + watchers
filteredItems: function() {
return this.items.filter(item => item.active);
}
},
methods: {
fetchData: function() {
// API calls and event handlers
}
},
mounted: function() {
this.fetchData();
}
});app.route('/dashboard/myfeature', 'myfeature', function() {
var view = new countlyVue.views.BackboneWrapper({
component: MyComponent
});
view.render();
});
// With parameters
app.route('/dashboard/myfeature/:id', 'myfeature-detail', function(id) {
var view = new countlyVue.views.BackboneWrapper({
component: MyDetailComponent,
vuex: { itemId: id }
});
view.render();
});Implement these methods for proper data management:
var MyView = countlyVue.views.create({
methods: {
// Called on initial load
initialize: function() {
this.loadData();
},
// Called when data should be refreshed
refresh: function() {
this.loadData();
},
// Called when view is being destroyed
reset: function() {
this.items = [];
}
}
});| Element | Convention | Example |
|---|---|---|
| Component variables | PascalCase | var UserProfile = ... |
| Data properties | camelCase | isLoading, userData |
| Methods | camelCase | handleClick, fetchData |
| Constants | UPPER_SNAKE | const FEATURE_NAME = 'users' |
| Private (avoid) | No $ or _ prefix | $internalVar |
| Element | Convention | Example |
|---|---|---|
| Component tags | kebab-case | <user-profile> |
| Props | kebab-case | :user-data="data" |
| Events | kebab-case | @click-save="handleSave" |
| Element | Convention | Example |
|---|---|---|
| Module names | camelCase | userModule |
| State properties | camelCase | currentUser |
| Getters | camelCase | activeUsers |
| Mutations | UPPER_SNAKE | SET_USER |
| Actions | camelCase | fetchUser |
<!-- ✅ Preferred -->
<button @click="handleClick" :disabled="isLoading">
Submit
</button>
<!-- ❌ Avoid verbose syntax -->
<button v-on:click="handleClick" v-bind:disabled="isLoading">
Submit
</button><!-- ✅ Use kebab-case in templates -->
<cly-drawer @close="onClose" :controls="drawerControls">
<template #default>Content</template>
</cly-drawer>
<!-- ❌ Don't use PascalCase in templates -->
<ClyDrawer></ClyDrawer><!-- ✅ Use v-if for conditional blocks -->
<template v-if="isLoaded">
<div class="content">{{ data }}</div>
</template>
<template v-else>
<cly-loading></cly-loading>
</template>
<!-- ✅ Use v-show for frequent toggles -->
<div v-show="isVisible" class="tooltip">Tooltip content</div><!-- ✅ Always use :key with v-for -->
<div v-for="item in items" :key="item._id">
{{ item.name }}
</div>
<!-- ❌ Don't use index as key for mutable lists -->
<div v-for="(item, index) in items" :key="index">Countly uses a custom template loading system since we don't use Single File Components.
Method 1: Template ID Reference
Templates are mounted to DOM and referenced by ID:
<!-- Template file: templates/main.html -->
<script type="text/x-template" id="drawer-template">
<div class="drawer-content">...</div>
</script>
<script type="text/x-template" id="card-template">
<div class="card">...</div>
</script>var DrawerComponent = countlyVue.views.create({
template: '#drawer-template',
// ...
});Method 2: BackboneWrapper Template Loading
Templates are automatically loaded and wrapped:
var exampleView = new countlyVue.views.BackboneWrapper({
component: MainView,
templates: [
// Load all templates in a file
"/vue-example/templates/empty.html",
// Load with namespace mapping
{
namespace: 'vue-example',
mapping: {
'table-template': '/vue-example/templates/table.html',
'main-template': '/vue-example/templates/main.html'
}
}
]
});The template ID is auto-generated as: {namespace}-{key} (e.g., vue-example-table-template).
When using namespace mapping, template files contain raw HTML (no script wrapper):
<!-- /vue-example/templates/main.html -->
<div class="vue-example-wrapper" v-bind:class="[componentId]">
<tg-view></tg-view>
<table-view @open-drawer="openDrawer"></table-view>
</div>BackboneWrapper automatically wraps it with the script tag.
// ✅ Good: Computed property
computed: {
fullName: function() {
return this.firstName + ' ' + this.lastName;
},
sortedItems: function() {
return [...this.items].sort((a, b) => a.name.localeCompare(b.name));
}
}
// ❌ Avoid: Data + watcher
data: function() {
return {
fullName: ''
};
},
watch: {
firstName: function() {
this.fullName = this.firstName + ' ' + this.lastName;
},
lastName: function() {
this.fullName = this.firstName + ' ' + this.lastName;
}
}// ❌ Expensive: Deep watcher
watch: {
formData: {
deep: true,
handler: function(newVal) {
this.validate(newVal);
}
}
}
// ✅ Better: Watch specific properties
watch: {
'formData.email': function(newVal) {
this.validateEmail(newVal);
},
'formData.password': function(newVal) {
this.validatePassword(newVal);
}
}// ✅ Correct: Parent passes data via props
// Child emits events to request changes
var ChildComponent = {
props: ['value'],
methods: {
updateValue: function(newValue) {
this.$emit('input', newValue); // Emit event to parent
}
}
};
// ❌ Wrong: Don't modify parent state directly
var ChildComponent = {
methods: {
updateValue: function(newValue) {
this.$parent.value = newValue; // DON'T DO THIS
}
}
};Each Countly plugin maps to a single namespaced Vuex module via countly{PluginName}.getVuexModule().
// countly.models.js
countlyVueExample.getVuexModule = function() {
var getEmptyState = function() {
return {
graphPoints: [],
isLoading: false
};
};
var getters = {
graphPoints: function(state) {
return state.graphPoints;
},
isLoading: function(state) {
return state.isLoading;
}
};
var mutations = {
SET_GRAPH_POINTS: function(state, points) {
state.graphPoints = points;
},
SET_LOADING: function(state, isLoading) {
state.isLoading = isLoading;
}
};
var actions = {
initialize: function(context) {
context.dispatch("refresh");
},
refresh: function(context) {
context.commit("SET_LOADING", true);
// Fetch data...
}
};
return countlyVue.vuex.Module("countlyVueExample", {
state: getEmptyState,
getters: getters,
mutations: mutations,
actions: actions
});
};var MainView = countlyVue.views.BaseView.extend({
methods: {
refresh: function() {
this.$store.dispatch("countlyVueExample/refresh");
}
},
beforeCreate: function() {
this.$store.dispatch("countlyVueExample/initialize");
},
computed: {
graphPoints: function() {
return this.$store.getters["countlyVueExample/graphPoints"];
}
}
});var vuex = [{
clyModel: countlyVueExample
}];
var exampleView = new countlyVue.views.BackboneWrapper({
component: MainView,
vuex: vuex,
templates: [...]
});Vue views are integrated with Backbone router via countlyVue.views.BackboneWrapper.
var exampleView = new countlyVue.views.BackboneWrapper({
component: MainView,
vuex: [{clyModel: countlyVueExample}],
templates: ["/vue-example/templates/main.html"]
});
app.vueExampleView = exampleView;
app.route("/vue/example", 'vue-example', function() {
this.renderWhenReady(this.vueExampleView);
});All Vue views should extend countlyVue.views.BaseView:
- Includes i18n mixin automatically
- Has refresh method support for auto-refresh
- Integrates with Countly's permission system
All custom components should extend countlyVue.components.BaseComponent:
- Provides access to common utilities
- Allows consistent component patterns
// View (entry point, routable)
var MainView = countlyVue.views.BaseView.extend({
template: '#main-template',
methods: {
refresh: function() { ... }
}
});
// Component (reusable, not routed)
var BackLinkComponent = countlyVue.components.BaseComponent.extend({
mixins: [countlyVue.mixins.i18n],
props: {
title: {type: String, required: false}
}
});The i18n mixin is automatically included in views:
<h1>{{ i18n("common.back") }}</h1>
<p>{{ i18n("common.welcome", userName) }}</p><!-- ✅ Safe: Automatic escaping in text interpolation -->
<span>{{ userInput }}</span>
<div :title="userInput"></div>
<!-- ⚠️ Use with caution: Only for pre-sanitized HTML -->
<div v-html="sanitizedHtml"></div>
<!-- ❌ NEVER: Raw user input in v-html -->
<div v-html="userProvidedContent"></div>// When you must render HTML
methods: {
getSafeHtml: function(rawInput) {
return countlyCommon.encodeHtml(rawInput);
}
}Always test with these strings:
var testStrings = [
"<script>alert('xss')</script>",
"\"onclick=\"alert('xss')\"",
"'&&&'",
"<img src=x onerror=alert('xss')>"
];The strings should display exactly as entered, not execute.
Add data-test-id attributes to all interactive elements:
<!-- Static test IDs -->
<button data-test-id="submit-form-button">Submit</button>
<input data-test-id="username-input" type="text">
<div data-test-id="error-message-container">{{ errorMessage }}</div>
<!-- Dynamic test IDs -->
<el-tab-pane
v-for="tab in tabs"
:key="tab.name"
:data-test-id="'tab-' + tab.name.toLowerCase().replace(/ /g, '-') + '-link'">
{{ tab.label }}
</el-tab-pane>When creating reusable components, accept test ID as a prop:
var MySelect = countlyVue.views.create({
props: {
testId: {
type: String,
default: ''
}
},
template: `
<div :data-test-id="testId + '-container'">
<select :data-test-id="testId + '-select'">
<option
v-for="opt in options"
:key="opt.value"
:data-test-id="testId + '-option-' + opt.value">
{{ opt.label }}
</option>
</select>
</div>
`
});Usage:
<my-select test-id="country-selector" :options="countries"></my-select>| Pattern | Example |
|---|---|
| Buttons | {action}-{context}-button → submit-form-button |
| Inputs | {field}-input → username-input |
| Links | {destination}-link → dashboard-link |
| Containers | {content}-container → user-list-container |
| Tabs | tab-{name}-link → tab-settings-link |
| Rows | {item}-row-{id} → user-row-123 |
When adding test IDs via JavaScript (not templates), rebuild assets:
npx grunt dist-allIn browser console:
// Find element by test ID
$('[data-test-id="login-submit-button"]')
// List all test IDs on page
$$('[data-test-id]').map(el => el.getAttribute('data-test-id'))Check if a component exists before creating a new one:
| Need | Component |
|---|---|
| Data tables | cly-datatable-n |
| Dropdowns | cly-select-x, cly-multi-select |
| Date pickers | cly-date-picker |
| Drawers | cly-drawer |
| Modals | el-dialog |
| Form inputs | el-input, el-checkbox, el-radio |
| Loading states | cly-loading |
| Empty states | cly-empty-view |
If you need a component that doesn't exist:
- Check if another plugin has implemented it
- Check if a suitable third-party component exists
- Discuss in team channel before implementing
methods: {
fetchData: function() {
var self = this;
self.isLoading = true;
CV.$.ajax({
type: "GET",
url: countlyCommon.API_PARTS.data.r + '/myfeature',
data: {
app_id: countlyCommon.ACTIVE_APP_ID
},
success: function(result) {
self.items = result.data || [];
},
error: function(xhr, status, error) {
CountlyHelpers.notify({
type: 'error',
message: jQuery.i18n.map['common.error']
});
},
complete: function() {
self.isLoading = false;
}
});
}
}data: function() {
return {
form: {
name: '',
email: ''
},
rules: {
name: [
{ required: true, message: 'Name is required' }
],
email: [
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Invalid email format' }
]
}
};
},
methods: {
submitForm: function() {
var self = this;
this.$refs.form.validate(function(valid) {
if (valid) {
self.saveData();
}
});
}
}methods: {
confirmDelete: function(item) {
var self = this;
CountlyHelpers.confirm(
jQuery.i18n.map['common.confirm-delete'],
'popStyleGreen',
function(result) {
if (result) {
self.deleteItem(item._id);
}
},
[jQuery.i18n.map['common.no'], jQuery.i18n.map['common.yes']],
{ title: jQuery.i18n.map['common.warning'] }
);
}
}Install Vue DevTools browser extension for:
- Component tree inspection
- State debugging
- Event tracking
- Vuex store inspection
// Component instance
console.log(this.$data);
console.log(this.$props);
// Vuex state
console.log(this.$store.state);| Issue | Solution |
|---|---|
| Component not updating | Check if data is reactive (defined in data()) |
| Props not working | Verify prop name uses kebab-case in template |
| Events not firing | Check $emit name matches @handler |
| Template not found | Verify path in countlyVue.T() is correct |
| Styles not applied | Run npx grunt sass after CSS changes |