This is an empty project for quickly starting up with nodejs. This is meant for projects which will have both a backend and a frontend. If you are writing only backend, use http://github.com/smartprix/nodejs_starter_project
res/js
res/css
res/js/components
res/img
res/assets
If you've just installed ubuntu, you can run these commands to install various softwares and packages that will help you run and develop this project.
sudo apt update -y
sudo apt install unzip -y
cd ~ && mkdir -p setup && cd setup
wget https://github.com/smartprix/node_starter_project_full/archive/master.zip
unzip -o master.zip
cd node_starter_project_full-master/setup
unzip -o ansible.zip -d ansible/
bash setup.sh
cd ansible
sudo bash dev_machine_setup
yarn
to install dependencies.starter
in your postgres servernpm run migrate
src/index.js
# Run eslint to check coding conventions
npm run lint
# Run eslint and try to fix linting errors
npm run lint:fix
# Run migration
npm run migrate
# Create a new migration
npm run migrate:create migration_name
# Run tests
npm test
# Compile Files
npm run build
# Start dev server (backend)
npm start
# Start dev server (basic frontend)
npm run basic
# Start dev server (admin frontend)
npm run admin
We use sm-utils
cfg
to manage config.
The configuration options can be written in config.js
and private/config.js
. Config options in both these files are merged, and the options present in private/config.js
are given higher priority over those present in config.js
(i.e. private config options overwrite the options in general config when merged).
Values from the config can be accessed via cfg(optionToBeRead)
. You can also provide a default value (such as cfg(foo, 'bar')
) while accessing any of the options. If the key/option is not found in the config, then the default value will be returned (thus, in case foo
is not present in the merged config the 'bar'
will be returned).
cfg
also has functions such as isDev
, isProd
, isTest
, etc. which returns true/false
on the basis of the current process' (node) environment.
exports.up = function(knex) {
return knex.schema.createTable('TableName', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('TableName');
};
Refer documentation at knex docs.
We use the xorm
ORM, which is based on ObjectionJS
.
xorm
provides with a Model
class which is basically a wrapper for the Model
class from ObjectionJS
with some added utilities (like soft delete, etc). Models can optionally define a jsonSchema
object that is used for input validation. All your relationships can be defined using the static relationMappings
property (as done for ObjectionJS models) or in the $relations
method (provided by xorm
).
The src/lib
folder has a models.js
file which is used to export the models to other places in the project. You can list your model in that file so that you can import models from src/lib/models
.
You can define GraphQL
schemas and resolvers for queries and mutations related to your model in the same module as your model and export them as schema
and resolvers
respectively.
The makeSchemaFromModules
function from gqutils
creates the schemas on the basis of your schema definitions and associates the resolvers wherever required. This process of creation of schemas is performed in src/graphql.js
file. So you would want to list your module in that file.
Following is a simple example of how each of the above mentioned files might look
import {Model} from 'xorm'; // import the 'Model' class from xorm
class Employee extends Model { // our model extends the 'Model' class
static softDelete = true; // this is an added utility, you can read more about this in xorm
// this jsonSchema will be used to validate the input
// whenever an instance of this model will be created
static jsonSchema = {
type: 'object',
properties: {
id: {type: 'string', required: true},
name: {type: 'string', required: true, minLength: 1},
},
};
// these are the relationships which this model has
static $relations() {
this.belongsTo('Employee', {
name: 'supervisor',
joinFrom: 'Employee.supervisorId',
joinTo: 'Employee.id',
});
}
}
export default Employee;
// this defines a graphql type
const Employee = {
graphql: 'type',
schema: ['admin'],
fields: {
id: 'ID!',
name: 'String!',
address: 'String',
post: 'String',
supervisorId: 'ID',
supervisor: 'Employee',
createdAt: 'String!',
updatedAt: 'String!',
},
};
// a graphql query
const getEmployee = {
graphql: 'query',
schema: ['admin'],
name: 'employee',
type: 'Employee',
args: {
$default: ['id', 'name'],
},
};
// a graphql mutation
const saveEmployee = {
graphql: 'mutation',
schema: ['admin'],
type: 'Employee',
args: {
$default: [
'id',
'name',
'address',
'post',
],
supervisorId: 'ID',
},
};
const deleteEmployee = {
graphql: 'mutation',
schema: ['admin'],
type: 'DeletedItem',
args: {
id: 'ID!',
},
};
export default {
Employee,
getEmployee,
saveEmployee,
deleteEmployee,
};
import {Employee} from '../models';
export default {
Query: {
employee: Employee.getFindOneResolver(),
},
Mutation: {
async saveEmployee(root, employee) {
return Employee.query().saveAndFetch(employee);
},
deleteEmployee: Employee.getDeleteByIdResolver(),
},
Employee: {
supervisor: employee => employee.loadByRelation('supervisor'),
},
};
For more information/examples on how you should define the schemas, you can go to https://github.com/smartprix/gqutils.
We use VueJS
for the frontend.
The vue components go into the res/js/components
folder. We use the single-file component
design, i.e. the template, script and style all go into the same file.
Most of the components in the frontend are added in sets of three (for example, Employee.vue
, Employees.vue
, EmployeeForm.vue
).
Employees.vue
- This component will have a list of employees with an option to edit any of the existing employees or another option to add a new employee. Clicking on any of these two options will cause the new component to open in a right modal.Employee.vue
- This component contains the data of a particular employee (and generally contains the EmployeeForm
component along with other components if required). It opens whenever you click on the edit option for any of the employees in the Employees
component.EmployeeForm.vue
- This component contains the form which contains the data.All requests to the server are handled using the $api
object. All api functions go inside the res/js/api
folders. To make an api call you can do this.$api.nameOfTheApiFunction
inside your components.
The frontend uses a lot of elements from the element UI Toolkit and the el-admin Toolkit.
Note: Elements with the prefix 'el-' come from the element
toolkit whereas those with the prefix 'ela-' come from the el-admin
toolkit.
Following are examples of how simple component files for the employee example (used in the backend) might look like
<template>
<ela-content-layout padding="0">
<div slot="head">
<h3>Employees</h3>
<div class="header-right">
<el-button
type="primary"
icon="el-icon-plus"
@click="$view.employee()">Add Employee
</el-button>
</div>
</div>
<div slot="filters">
<el-row type="flex">
<ela-filter-item label="Post" :span="6">
<el-select
size="small"
clearable
v-model="filters.post"
@change="handleFilterChange">
<el-option value="softwareDeveloper">Software Developer</el-option>
<el-option value="softwareDevelopmentIntern">Software Development Intern</el-option>
</el-select>
</ela-filter-item>
<ela-filter-item label="Search" :span="6" float="right">
<el-input
icon="el-icon-search"
size="small"
v-model="filters.search"
@click="handleFilterChange"
@keyup.native.enter="handleFilterChange">
</el-input>
</ela-filter-item>
</el-row>
</div>
<el-table
:data="employees.nodes"
style="width: 100%"
stripe
border
v-loading="loadingSelfData">
<el-table-column label="View" align="center" width="90">
<el-button
slot-scope="scope"
type="primary"
size="small"
@click="$view.employee(scope.row)">Details
</el-button>
</el-table-column>
<el-table-column prop="id" label="Id" width="60"></el-table-column>
<el-table-column prop="name" label="Name"></el-table-column>
<el-table-column prop="post" label="Post"></el-table-column>
</el-table>
<div slot="foot">
<div class="footer-right">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="filters.page"
:page-sizes="[20, 50, 100, 250, 500]"
:page-size="filters.count"
layout="total, sizes, prev, pager, next, jumper"
:total="employees.totalCount">
</el-pagination>
</div>
</div>
</ela-content-layout>
</template>
<script>
import {paginationMixin} from 'vutils';
export default {
name: 'Employees',
mixins: [ paginationMixin() ],
data() {
return {
employees: {},
filters: {
search: '',
post: '',
page: 1,
count: 20,
},
};
},
methods: {
loadSelfData(filters) {
return this.$api.getEmployees(filters).then((employees) => {
this.employees = employees;
});
},
},
events: {
employeeMutated() {
this.reloadSelfData();
},
},
};
</script>
The pagination mixin used in the above code is one of the many utilities in the vutils
package, which will come in handy while working on the project.
<template>
<ela-content-layout>
<div slot="head">
<h3>
<span v-if="isAdd">Add </span>Employee
<small v-if="!isAdd">{{ data.name }}</small>
</h3>
<div class="header-right">
<el-button
type="danger"
icon="el-icon-delete"
@click="deleteEmployee(data)"
v-if="!isAdd">
</el-button>
</div>
</div>
<el-tabs type="card" slot="tabs">
<el-tab-pane label="Details" v-loading="loading">
<employee-form
:form-data="employee"
@done="$emit('done')">
</employee-form>
</el-tab-pane>
</el-tabs>
</ela-content-layout>
</template>
<script>
import EmployeeForm from './EmployeeForm.vue';
export default {
name: 'Employee',
reEvents: {delete: 'done'},
components: {
EmployeeForm,
},
props: {
data: {
type: Object,
modify: 'employee',
},
fetch: Boolean,
},
data: () => ({
loading: false,
}),
computed: {
isAdd() {
return !(this.data && this.data.id);
},
},
created() {
this.loadEmployee();
},
methods: {
loadEmployee() {
if (this.fetch) {
this.loading = true;
this.$api.getEmployee(this.data.id).then((employee) => {
this.employee = employee;
this.loading = false;
});
}
},
deleteEmployee(employee) {
this.$confirm(
'Are you sure?',
'Delete Employee',
{type: 'warning'},
).then(() => {
this.$api.deleteEmployee(employee.id)
.then(() => {
this.$notify({
title: 'Success',
message: 'Employee Deleted Successfully',
type: 'success',
});
this.$bus.$emit('employeeMutated', employee);
this.$emit('done');
}).catch((res) => {
this.$notify({
title: 'Danger',
message: 'Unable to Delete',
type: 'danger',
});
console.log(res);
this.$emit('done');
});
}).catch(() => {});
},
},
};
</script>
<template>
<div v-loading="loading">
<el-form
ref="form"
:model="employee"
:rules="rules"
label-position="top">
<el-form-item prop="globalError" class="form-global-error"></el-form-item>
<el-row :gutter="12">
<el-col :span="16">
<el-form-item label="Employee Name" prop="name">
<el-input v-model.trim="employee.name"></el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Post" prop="post">
<el-select v-model="employee.post">
<el-option value="softwareDeveloper">Software Developer</el-option>
<el-option value="softwareDevelopmentIntern">Software Development Intern</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Address" prop="address">
<el-input type="textarea" :rows="5" v-model.trim="employee.address"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submit">Submit</el-button>
<el-button type="text" @click="$emit('done')">Cancel</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
name: 'EmployeeForm',
props: {
formData: {
type: Object,
default: () => ({
name: '',
address: '',
post: 'Software Developer',
}),
modify: 'employee',
},
},
data() {
return {
rules: {
name: [{required: true, message: 'Please Enter Name'}],
post: [{required: true, message: 'Please Choose Post'}],
},
loading: false,
};
},
methods: {
submit() {
this.$utils.clearFormErrors(this.$refs.form);
this.$refs.form.validate((valid) => {
if (!valid) return;
this.loading = true;
this.$api.saveEmployee(this.employee).then(() => {
this.$notify({
title: 'Success',
message: 'Employee Saved Successfully',
type: 'success',
});
this.loading = false;
this.$bus.$emit('employeeMutated', this.employee);
this.$emit('done');
}).catch((res) => {
this.$utils.setFormErrors(this.$refs.form, res.userErrors);
this.loading = false;
});
});
},
},
};
</script>
The api functions used should be present in the js/api
folder. You generally keep all the related api functions in the same file, thus all the functions used in the above examples might look like this (present in employee.js
file in the js/api
folder)
import {query, mutation, toGqlArg as arg} from '../helpers';
const fields = `
id
name
address
post
`;
function getEmployee(id) {
return query(`employee(id: ${id}) { ${fields} }`)
.then(data => data.employee);
}
function getEmployees(search) {
return query(`employees(${arg(search)}) {
nodes { ${fields} }
totalCount
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
edgeCount
}
}`)
.then(data => data.employees);
}
function saveEmployee(employee) {
const pick = ['id', 'name', 'address', 'post'];
return mutation(`saveEmployee(${arg(employee, pick)}) { ${fields} }`)
.then(data => data.saveEmployee);
}
function deleteEmployee(employeeId) {
return mutation(`deleteEmployee(id: ${employeeId}) {
id
}`);
}
export {
getEmployee,
getEmployees,
saveEmployee,
deleteEmployee,
};
The above example uses some functions imported from the helpers.js
file. This file contains various kind of helper functions used in the project. You can go through the file to get an idea what each of these function does (they are basically used to wrap the query/mutation in the request sent to the server).
All tests are added inside the test
folder. We use the mocha
test framework along with the chai
assertion library.
Names of all test files must end with .test.js
. To run all the tests you can use npm run test
from the terminal.
The file test/index.test.js
contains a simple example describing what a test should look like.
For API testing, a file named api.test.js
already exists in the test
folder. It tests all the queries and mutations listed in the test/api
folder.
You can describe tests for APIs as objects.
Objects describing test case for a query should have a query
, and either an expectFunction
or an expect
.
query
- the GraphQL queryexpectFunction
- a custom function describing what the test expectsexpect
- the object/value to be expected as the resultAn object describing a test for a query might look like as follows:
const employee = {
query: `query {
employee(id: 1) {
id
name
post
}
}`,
expect: {
id: '1',
name: 'XYZ',
post: 'Software Developer',
},
};
Objects describing test case for a mutation should have a mutation
, a query
and an expect
.
mutation
- the GraphQL mutationquery
- a function which expects an id and returns a GraphQL query to be executed in order to test whether the mutation worked correctlyexpect
- the object/value to be expected as the result of the query (not the mutation)Note: The query
is executed only in case an id
is received in the response of the mutation. And that id
itself is sent as an argument to the query
function
An object describing a test for a query might look as follows:
const saveEmployee = {
mutation: `mutation {
saveEmployee(name: "XYZ", post: "Software Developer") {
id
}
}`,
query: id => `query {
employee(id: ${id}) {
name
post
}
}`,
expect: {
employee: {
name: 'XYZ',
post: 'Software Developer',
},
},
};