My PEVN Stack Practice App: PgNote
Node js, PostgreSQL, Express js, Vue js, Vuex
I wanted to make something for practice in PEVN Stack then I made a note-taking app and deploy on heroku. ( https://pgnote-api.herokuapp.com/ ) In this app you can create, update, delete, search notes and list them partially. You can add to notes title, description and color.
Here are repositories
https://github.com/canerdemirci/pgnote_api https://github.com/canerdemirci/pgnote_client
I'll be waiting your advices and critics.
I think to use .NET, Entity Framework and Blazor in the future but I also love Vue js and Node js.
Backend
I have a database model file (note.js). Note object represents "notes" table in database.
module.exports = {
name: 'notes',
shortName: 'no',
columns: {
id: {
name: 'note_id',
notnull: true,
primary: true
},
title: {
name: 'title',
maxLength: 150,
},
description: {
name: 'description'
},
color: {
name: 'colorhex',
default: '000'
},
createdAt: {
name: 'created_at',
},
updatedAt: {
name: 'updated_at'
},
},
}
SQL
CREATE TABLE public.notes (
note_id uuid DEFAULT public.uuid_generate_v4() NOT NULL,
description text,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at date,
title character varying(150),
colorhex character varying(7)
);
And an object performing crud operations (notesCrud.js)
const db = require('./db');
const noteModel = require('./models/note');
module.exports = {
get: async function(id) {
let query = `SELECT * FROM ${noteModel.name}
WHERE ${noteModel.columns.id.name} = $1`;
const note = await db.query(query, [id]);
if (note.rowCount == 0)
return null;
return note.rows[0];
},
getAll: async function(limit, offset) {
let query = `SELECT * FROM ${noteModel.name}
ORDER BY ${noteModel.columns.createdAt.name}
DESC LIMIT $1 OFFSET $2`;
const notes = await db.query(query, [limit, offset]);
if (notes.rowCount < 1)
return null;
return notes.rows;
},
search: async function(like) {
let query = `SELECT * FROM ${noteModel.name}
WHERE ${noteModel.columns.title.name} LIKE '%${like}%'
OR ${noteModel.columns.description.name} LIKE '%${like}%'`;
const notes = await db.query(query);
if (notes.rowCount < 1)
return null;
return notes.rows;
},
create: async function(note) {
let query = `INSERT INTO ${noteModel.name} (
${noteModel.columns.title.name},
${noteModel.columns.description.name},
${noteModel.columns.color.name}
) VALUES ($1, $2, $3) RETURNING *`;
const result = await db.query(query,
[note.title, note.description, note.color]);
if (!result) return null;
return result.rows[0];
},
delete: async function(id) {
let query = `DELETE FROM ${noteModel.name}
WHERE ${noteModel.columns.id.name} = $1`;
const result = await db.query(query, [id]);
if (result.rowCount == 0)
return false;
return true;
},
update: async function(note) {
let query = `UPDATE ${noteModel.name}
SET ${noteModel.columns.title.name} = $1,
${noteModel.columns.description.name} = $2,
${noteModel.columns.color.name} = $3,
${noteModel.columns.updatedAt.name} = NOW()::DATE
WHERE ${noteModel.columns.id.name} = $4`;
const result = await db.query(query,
[note.title, note.description, note.color, note.id]);
if (result.rowCount == 0)
return false;
return true;
}
}
There aren't any try-catch block because pg query function throws when there is an error.
/note Route
const router = require('express').Router();
const notesCrud = require('../database/notesCrud');
const noteModel = require('../database/models/note');
function mapNote(note) {
return {
id: note.note_id,
title: note.title,
description: note.description,
color: note.colorhex,
createdAt: note.created_at,
updatedAt: note.updated_at
};
}
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
let note = await notesCrud.get(id);
if (note)
note = mapNote(note);
if (!note)
return res.status(204).send();
return res.status(200).json(note);
} catch (error) {
console.error(error);
next();
}
});
router.get('/', async (req, res, next) => {
try {
const { page, limit, like } = req.query;
let notes = null;
if (!like)
notes = await notesCrud.getAll(limit, (page - 1) * limit);
else
notes = await notesCrud.search(like);
if (notes)
notes = notes.map(n => mapNote(n));
if (!notes)
return res.status(204).send();
return res.status(200).json(notes);
} catch (error) {
console.error(error);
next();
}
});
router.post('/', async (req, res, next) => {
const note = req.body;
try {
let createdNote = await notesCrud.create(note);
if (createdNote)
createdNote = mapNote(createdNote);
if (!createdNote)
return next();
return res.status(201).json(createdNote);
} catch (error) {
console.error(error);
if (note.title.length > noteModel.columns.title.maxLength) {
return res.status(400).send('Title can be 150 characters maximum.');
}
next();
}
});
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const result = await notesCrud.delete(id);
if (!result)
return next();
return res.status(204).send();
} catch (error) {
console.error(error);
next();
}
});
router.put('/:id', async (req, res, next) => {
try {
const note = req.body;
const result = await notesCrud.update(note);
if (!result)
return next();
return res.status(204).send();
} catch (error) {
console.error(error);
next();
}
});
module.exports = router;
mapNote(note) function used translate the ojbect coming from database.
And I have a test js file to populate notes to database and delete all for my tests.
require('dotenv').config();
const db = require('./database/db');
const NOTE_COLORS = [
'#fb8787',
'#7ce1ef',
'#e27eff',
'#85fb86',
'#fb882e',
'#fbf47f',
'#f9f9f9'
];
var sampleNotes = [];
const words = [
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'curabitur', 'vel', 'hendrerit', 'libero',
'eleifend', 'blandit', 'nunc', 'ornare', 'odio', 'ut',
'orci', 'gravida', 'imperdiet', 'nullam', 'purus', 'lacinia',
'a', 'pretium', 'quis', 'congue', 'praesent', 'sagittis',
'laoreet', 'auctor', 'mauris', 'non', 'velit', 'eros',
'dictum', 'proin', 'accumsan', 'sapien', 'nec', 'massa',
'volutpat', 'venenatis', 'sed', 'eu', 'molestie', 'lacus',
'quisque', 'porttitor', 'ligula', 'dui', 'mollis', 'tempus',
'at', 'magna', 'vestibulum', 'turpis', 'ac', 'diam',
'tincidunt', 'id', 'condimentum', 'enim', 'sodales', 'in',
'hac', 'habitasse', 'platea', 'dictumst', 'aenean', 'neque',
'fusce', 'augue', 'leo', 'eget', 'semper', 'mattis',
'tortor', 'scelerisque', 'nulla', 'interdum', 'tellus', 'malesuada',
'rhoncus', 'porta', 'sem', 'aliquet', 'et', 'nam',
'suspendisse', 'potenti', 'vivamus', 'luctus', 'fringilla', 'erat',
'donec', 'justo', 'vehicula', 'ultricies', 'varius', 'ante',
'primis', 'faucibus', 'ultrices', 'posuere', 'cubilia', 'curae',
'etiam', 'cursus', 'aliquam', 'quam', 'dapibus', 'nisl',
'feugiat', 'egestas', 'class', 'aptent', 'taciti', 'sociosqu',
'ad', 'litora', 'torquent', 'per', 'conubia', 'nostra',
'inceptos', 'himenaeos', 'phasellus', 'nibh', 'pulvinar', 'vitae',
'urna', 'iaculis', 'lobortis', 'nisi', 'viverra', 'arcu',
'morbi', 'pellentesque', 'metus', 'commodo', 'ut', 'facilisis',
'felis', 'tristique', 'ullamcorper', 'placerat', 'aenean', 'convallis',
'sollicitudin', 'integer', 'rutrum', 'duis', 'est', 'etiam',
'bibendum', 'donec', 'pharetra', 'vulputate', 'maecenas', 'mi',
'fermentum', 'consequat', 'suscipit', 'aliquam', 'habitant', 'senectus',
'netus', 'fames', 'quisque', 'euismod', 'curabitur', 'lectus',
'elementum', 'tempor', 'risus', 'cras'
];
function produceDummyText(wordLength) {
let txt = '';
for (let i=0; i<wordLength; i++) {
txt += words[Math.floor(Math.random() * 178)] + ' ';
}
return txt;
}
for (let i=0; i<100; i++) {
sampleNotes.push(
{
title: produceDummyText(3),
description: produceDummyText(35),
color: NOTE_COLORS[Math.floor(Math.random() * 7)]
}
);
}
function fillBySampleNotes() {
for (let i=0; i<sampleNotes.length; i++) {
db.query(`INSERT INTO notes (title, description, colorhex) VALUES ($1, $2, $3)`,
[sampleNotes[i].title, sampleNotes[i].description, sampleNotes[i].color]);
}
}
function deleteAll() {
db.query('DELETE FROM notes');
}
//deleteAll();
fillBySampleNotes();
Frontend
App.vue
NoteEditForm component is always present in the app. We show it when we need it. It is used for creating a note, updating a note and showing the note's details.
<template>
<div id="app">
<router-view/>
<NoteEditForm v-if="show" />
</div>
</template>
<script>
import NoteEditForm from '@/components/NoteEditForm';
export default {
name: 'App',
components: {
NoteEditForm
},
computed: {
show() {
return this.$store.state.noteEditFormShow;
},
}
}
</script>
There is only one view: Home.vue (Main page)
<template>
<div>
<!-- Search Box -->
<SearchBox v-if="!notesLoading && !notesError && notes.length > 0" />
<!-- Loading indicators -->
<div v-if="notesLoading" class="note-grid">
<LoadingNoteCard v-for="(card, index) in 10" :key="index" />
</div>
<!-- Error Message -->
<div v-else-if="notesError" class="notes-loading-error">
<h1>Server Error!</h1>
<h3>Please Try Again Later!</h3>
</div>
<!-- Notes -->
<NoteGrid v-else-if="notes.length > 0" :notes="notes" />
<!-- If there is no note -->
<div v-else class="notes-completed-icon">
<span class="material-icons">task</span>
</div>
<!-- Load More Notes Button -->
<button v-if="!notesLoading && !notesError && notes.length > 0 && moreNoteButtonShow" @click="loadMoreNotes" class="more-button">
<span class="material-icons">more_horiz</span>
</button>
<!-- Note Add Button -->
<NoteAddButton @click.native="showNoteEditForm()" />
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import NoteGrid from '@/components/NoteGrid';
import LoadingNoteCard from '@/components/LoadingNoteCard';
import NoteAddButton from '@/components/NoteAddButton';
import SearchBox from '@/components/SearchBox';
export default {
name: 'Home',
components: {
NoteGrid,
LoadingNoteCard,
NoteAddButton,
SearchBox
},
computed: {
...mapState([
'notes',
'notesError',
'notesLoading',
'moreNoteButtonShow'
]),
},
mounted() {
this.fetchNotes(null);
},
methods: {
...mapActions([
'fetchNotes',
'fetchAndAddToNotes',
'notesNextPage'
]),
loadMoreNotes() {
// Next page (offset = page * limit)
this.notesNextPage();
// Fetch notes and add note list
this.fetchAndAddToNotes();
},
showNoteEditForm() {
this.$store.dispatch('toggleNoteEditForm', null);
}
},
}
</script>
<style scoped>
.notes-loading-error {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
color: #fff;
gap: 1rem;
text-align: center;
justify-content: center;
}
.notes-completed-icon {
text-align: center;
position: fixed;
top: 50%;
left: 50%;
}
.notes-completed-icon span {
font-size: 72px;
color: #03e503;
}
.more-button {
padding: 1rem;
border-radius: 5px;
display: block;
width: 10rem;
margin: 1rem auto;
cursor: pointer;
}
.more-button:hover {
opacity: .6;
}
.more-button span {
font-size: 48px;
}
</style>
NoteGrid component lists the notes. It is a css grid.
If tehere is no note in the database.
<div v-else class="notes-completed-icon">
<span class="material-icons">task</span>
</div>
Material task icon to say completed and deleted all works.
Loading notes
They're like NoteCard component but they have css animation.
mounted() {
this.fetchNotes(null);
},
...
methods: {
...mapActions([
'fetchNotes',
'fetchAndAddToNotes'
]),
<div v-if="notesLoading" class="note-grid">
<LoadingNoteCard v-for="(card, index) in 10" :key="index" />
</div>
notesLoading variable comes from vuex store. mapState, mapActions or this.$store.state, this.$store.commit, this.$store.dispatch used for retrieve vuex store state variables, actions.
If an error occurs when fetching notes from database
<div v-else-if="notesError" class="notes-loading-error">
<h1>Server Error!</h1>
<h3>Please Try Again Later!</h3>
</div>
Load more notes
methods: {
...mapActions([
'fetchNotes',
'fetchAndAddToNotes',
'notesNextPage'
]),
loadMoreNotes() {
// Next page (offset = page * limit)
this.notesNextPage();
// Fetch notes and add note list
this.fetchAndAddToNotes();
},
},
...
actions: {
fetchAndAddToNotes({ commit, state }) {
commit('setAddedNotesLoading', true);
axios.get(constants.baseUrl + constants.endpoints.note + `?
page=${state.notesCurrentPage}&limit=${state.notesPageLimit}`)
.then(response => {
if (response.status == 200) {
commit('addNotesToEnd', response.data);
commit('setMoreNoteButtonShow', response.data.length == state.notesPageLimit);
}
else
commit('setMoreNoteButtonShow', false);
})
.catch(error => commit('setNotesError', error))
.finally(() => commit('setAddedNotesLoading', false));
},
}
Delete a note
When user click delete button app makes delete request to /note endpoint with note's id. If response status code is 204 No Content the note is deleted from vuex notes array thus deleted also from ui.
mutations: {
...
removeNote: (state, payload) => {
const arr = [...state.notes];
const noteIndex = arr.findIndex(f => f.id == payload);
arr.splice(noteIndex, 1);
state.notes = arr;
},
}
Update a note
mutations: {
...
changeNote: (state, payload) => {
const arr = [...state.notes];
arr[arr.findIndex(f => f.id == payload.id)] = payload;
state.notes = arr;
},
}
...
axios.put(constants.baseUrl + constants.endpoints.note + `/${note.id}`, note)
.then(result => {
if (result.status == 204) {
this.toggleNoteEditForm();
this.$store.commit('changeNote', {
...note,
createdAt,
updatedAt
});
} else {
this.setErrorMessage(constants.INTERNAL_SERVER_ERROR);
}
})
.catch(error => {
console.error(error);
this.setErrorMessage(constants.INTERNAL_SERVER_ERROR);
})
.finally(() => this.saveBtnDisabled = false);
Create a note
mutations: {
...
addNotesToStart: (state, payload) => {
if (state.notes.length > 0)
state.notes.splice(0, 0, ...payload);
else {
state.notes.push(...payload);
}
},
}
...
axios.post(constants.baseUrl + constants.endpoints.note, note)
.then(result => {
if (result.status == 201) {
this.toggleNoteEditForm();
this.$store.commit('addNotesToStart', [result.data]);
if (this.notes.length > this.notesCurrentPage * this.notesPageLimit) {
this.fetchNotes();
}
} else {
this.setErrorMessage(constants.INTERNAL_SERVER_ERROR);
}
})
.catch(error => {
console.error(error);
this.setErrorMessage(constants.INTERNAL_SERVER_ERROR);
})
.finally(() => this.saveBtnDisabled = false);
Search in notes
Vuex Store
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import constants from '../constants';
Vue.use(Vuex)
export default new Vuex.Store({
state: {
notes: [],
notesLoading: true,
addedNotesLoading: false,
notesError: null,
noteEditFormShow: false,
selectedNoteColor: null,
selectedNote: null,
notesPageLimit: 20,
notesCurrentPage: 1,
moreNoteButtonShow: true,
searchFinished: true,
},
mutations: {
setNotes: (state, payload) => state.notes = payload,
addNotesToStart: (state, payload) => {
if (state.notes.length > 0)
state.notes.splice(0, 0, ...payload);
else {
state.notes.push(...payload);
}
},
addNotesToEnd: (state, payload) => {
state.notes.push(...payload);
},
changeNote: (state, payload) => {
const arr = [...state.notes];
arr[arr.findIndex(f => f.id == payload.id)] = payload;
state.notes = arr;
},
removeNote: (state, payload) => {
const arr = [...state.notes];
const noteIndex = arr.findIndex(f => f.id == payload);
arr.splice(noteIndex, 1);
state.notes = arr;
},
setNotesLoading: (state, payload) => state.notesLoading = payload,
setSearchFinished: (state, payload) => state.searchFinished = payload,
setAddedNotesLoading: (state, payload) => state.addedNotesLoading = payload,
setNotesError: (state, payload) => state.notesError = payload,
setNoteEditFormShow: (state, payload) => state.noteEditFormShow = payload,
setSelectedNoteColor: (state, payload) => state.selectedNoteColor = payload,
setSelectedNote: (state, payload) => state.selectedNote = payload,
setNotesCurrentPage: (state, payload) => state.notesCurrentPage = payload,
setMoreNoteButtonShow: (state, payload) => state.moreNoteButtonShow = payload,
},
actions: {
fetchNotes({ commit, state }, searchStr = null) {
if (!searchStr)
commit('setNotesLoading', true);
else
commit('setSearchFinished', false);
axios.get(constants.baseUrl + constants.endpoints.note + (searchStr ? `?like=${searchStr}` : `?page=1&limit=${state.notesPageLimit}`))
.then(response => {
if (response.status == 200) {
commit('setNotes', response.data);
commit('setMoreNoteButtonShow', response.data.length == state.notesPageLimit);
commit('setNotesCurrentPage', 1);
}
else {
commit('setMoreNoteButtonShow', false);
}
})
.catch(error => commit('setNotesError', error))
.finally(() => {
commit('setNotesLoading', false);
commit('setSearchFinished', true);
});
},
fetchAndAddToNotes({ commit, state }) {
commit('setAddedNotesLoading', true);
axios.get(constants.baseUrl + constants.endpoints.note + `?page=${state.notesCurrentPage}&limit=${state.notesPageLimit}`)
.then(response => {
if (response.status == 200) {
commit('addNotesToEnd', response.data);
commit('setMoreNoteButtonShow', response.data.length == state.notesPageLimit);
}
else
commit('setMoreNoteButtonShow', false);
})
.catch(error => commit('setNotesError', error))
.finally(() => commit('setAddedNotesLoading', false));
},
toggleNoteEditForm({ commit, state }, note) {
commit('setNoteEditFormShow', !state.noteEditFormShow);
if (note) {
commit('setSelectedNote', note);
} else {
commit('setSelectedNote', null);
}
},
notesNextPage({ commit, state }) {
commit('setNotesCurrentPage', state.notesCurrentPage + 1);
}
},
modules: {
}
})
For Deploy Heroku
vue.config.js
const path = require('path');
module.exports = {
outputDir: path.resolve(__dirname, '../public'),
devServer: {
proxy: process.env.PRODUCTION ? 'https://pgnote-api.herokuapp.com/' : 'http://localhost:5000/'
}
}
db.js
const Pool = require('pg').Pool;
var pool;
// Heroku or Local
if (process.env.DATABASE_URL) {
pool = new Pool({
ssl: {
rejectUnauthorized: false,
},
connectionString: process.env.DATABASE_URL
});
} else {
pool = new Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
database: process.env.DB_NAME
});
}
module.exports = pool;