My PEVN Stack Practice App: PgNote

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.

1.png

<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.

2.gif

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

3.gif

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

4.gif 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

5.gif

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

Animation.gif

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

6.gif

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;