web developer’s notes. Iteration three / Habr

Hello, friends! I continue to share with you notes about Docker.

The notes consist of 4 parts: 2 theoretical and 2 practical. To be more specific:

In this part we will develop a simple application consisting of three services and a database, and in the final part we will “containerize” it.

The repository with the application code.

If you are interested in this, please under the cat.

Project preparation and setup

It is assumed that you have at least briefly familiarized yourself with the contents of the previous parts or studied other materials on working with Docker. However, in this part Docker it will be just a little bit.

It is also assumed that your machine has Docker and Node.js.

It is good if your machine has Yarn and you have experience with React, Vue, Node.js, PostgreSQL and sh or bash (all this is optional).

As I said, our application will consist of three services:

  • client on React.js;
  • admins on Vue.js;
  • servers (API) on Node.js.

As a database we will use PostgreSQL, and to interact with it — Prisma.

The functionality of our application will be as follows:

  • in the admin panel, settings are set for the greeting, the theme and the base font size;
  • these settings are recorded in the database and applied on the client;
  • a “tudushka” is implemented on the client;
  • tasks are written to the database;
  • all this is served by the server.

Create a directory for the project, go to it and create a couple more directories:

mkdir docker-test

cd !$ # docker-test

mkdir services sh uploads

In the directory services our services will be located in the directory sh – scripts for the terminal, directory uploads we will not use it, but it usually stores various files uploaded by the admin or users.

Go to the directory services, create a directory for the API, generate a client template using Create React App and the admin panel template using Vue CLI:

cd services

mkdir api

yarn create react-app client
# or
npx create-react-app client

yarn create vue-app admin
# or
npx vue create admin

Let’s start with the API.

API

Go to the directory api, initialize Node.js-проект and install dependencies:

cd api

yarn init -yp
# or
npm init -y

# производственные зависимости
yarn add express cors
# зависимости для разработки
yarn add -D nodemon prisma

  • express — Node.js-фреймворк for web server development;
  • cors – utility for working with CORS;
  • nodemon is a utility for running a server for development;
  • prisma is the core of the ORM, which we will use to interact with postgres.

Initialize prisma:

npx prisma init

This leads to the generation of a directory prisma, as well as files prisma/schema.prisma and .env.

We define the generator, data source and models in the file prisma/schema.prisma:

// https://pris.ly/d/prisma-schema
generator client {
  provider      = "prisma-client-js"
  // это нужно для контейнера
  binaryTargets = ["native"]
}

datasource db {
  provider = "postgresql"
  // путь к БД извлекается из переменной среды окружения `DATABASE_URL`
  url      = env("DATABASE_URL")
}

// модель для настроек
model Settings {
  id             Int      @id @default(autoincrement())
  created_at     DateTime @default(now())
  updated_at     DateTime @updatedAt
  greetings      String
  theme          String
  base_font_size String
}

// модель для задачи
model Todo {
  id         Int      @id @default(autoincrement())
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  text       String
  done       Boolean
}

Defining the path to the database in the file .env:

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mydb?schema=public

Here:

  • postgres – username and password;
  • localhost – the host on which the server is running postgres;
  • 5432 – the port on which the server is running postgres;
  • mydb – name of the database.

Defining the command to run контейнера postgres in the file sh/db (without extension):

docker run --rm --name postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=mydb -dp 5432:5432 -v $HOME/docker/volumes/postgres:/var/lib/postgresql/data postgres

Please note: if you work on Mac, you will need to grant yourself permission to execute code from a file sh/db. It can be done like this:

# мы находимся в директории `sh`
chmod +x db
# or
sudo chmod +x db

Being in the root directory of the project, open the terminal and execute the command:

sh/db

The image is being loaded postgres from Docker Hub and launching a container called postgres.

Please note: sometimes an error may occur due to the fact that port 5432 is occupied by another process. In this case, you need to find PID this process and “kill” it. On Mac this is done like this:

# получаем `PID` процесса, запущенного на порту `5432`
sudo lsof -i :5432
# предположим, что `PID` имеет значение `103`
# "убиваем" процесс
sudo kill 103

You can verify that the container is running by running the command docker ps:

Or by running Docker Desktop:

Or in the section Individual Containers расширения Docker for VSCode:

Performing migration:

# мы находимся в директории `api`
# migrate dev - миграция для разработки
# --name init - название миграции
npx prisma migrate dev --name init

This leads to the generation of the file prisma/migrations/[Date]-init/migration.sql, connecting to the database, creating tables in it, installing and configuring @prisma/client.

Creating a file prisma/seed.js with a code for filling the database with initial data:

import Prisma from '@prisma/client'
const { PrismaClient } = Prisma

// инициализируем клиента
const prisma = new PrismaClient()

// начальные настройки
const initialSettings = {
  greetings: 'Welcome to Docker Test App',
  theme: 'light',
  base_font_size: '16px'
}

// начальные задачи
const initialTodos = [
  {
    text: 'Eat',
    done: true
  },
  {
    text: 'Code',
    done: true
  },
  {
    text: 'Sleep',
    done: false
  },
  {
    text: 'Repeat',
    done: false
  }
]

async function main() {
  try {
    // если таблица настроек является пустой
    if (!(await prisma.settings.findFirst())) {
      await prisma.settings.create({ data: initialSettings })
    }
    // если таблица задач является пустой
    if (!(await (await prisma.todo.findMany()).length)) {
      await prisma.todo.createMany({ data: initialTodos })
    }
    console.log('Database has been successfully seeded 🚀 ')
  } catch (e) {
    console.log(e)
  } finally {
    await prisma.$disconnect()
  }
}

main()

In package.json we define the type of server code (module), commands to start the server in development and production mode, as well as a command to fill the database with initial data:

"type": "module",
"scripts": {
  "dev": "nodemon",
  "start": "prisma generate && prisma migrate deploy && node index.js"
},
"prisma": {
  "seed": "node prisma/seed.js"
}

Filling the database with initial data:

# мы находимся в директории `api`
npx prisma db seed

Opening our database in interactive mode:

npx prisma studio

This causes the browser tab to open at http://localhost:5555:

We are starting to develop the server.

The server structure will be as follows:

- routes
  - index.js
  - settings.routes.js - маршруты (роуты) для настроек
  - todo.routes.js - роуты для задач
- index.js

File Contents index.js:

// импортируем библиотеки и утилиты
import express from 'express'
import cors from 'cors'
import Prisma from '@prisma/client'
import apiRoutes from './routes/index.js'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const { PrismaClient } = Prisma

// создаем и экспортируем экземпляр `prisma`
export const prisma = new PrismaClient()

// путь к текущей директории
const __dirname = dirname(fileURLToPath(import.meta.url))

// создаем экземпляр приложения `express`
const app = express()

// отключаем `cors`
app.use(cors())
// включаем парсинг `json` в объекты
app.use(express.json())

// это пригодится нам при запуске приложения в производственном режиме
if (process.env.ENV === 'prod') {
  // обратите внимание на пути
  // путь к текущей директории + `client/build`
  const clientBuildPath = join(__dirname, 'client', 'build')
  // путь к текущей директории + `admin/dist`
  const adminDistPath = join(__dirname, 'admin', 'dist')

  // обслуживание статических файлов
  // клиент будет доступен по пути сервера
  app.use(express.static(clientBuildPath))
  app.use(express.static(adminDistPath))
  // админка будет доступна по пути сервера + `/admin`
  app.use('/admin', (req, res) => {
    res.sendFile(join(adminDistPath, decodeURIComponent(req.url)))
  })
}
// роутинг
app.use('/api', apiRoutes)

// обработчик ошибок
app.use((err, req, res, next) => {
  console.log(err)
  const status = err.status || 500
  const message = err.message || 'Something went wrong. Try again later'
  res.status(status).json({ message })
})

// запускаем сервер на порту 5000
app.listen(5000, () => {
  console.log(`Server ready 🚀 `)
})

Consider the routes.

Let’s start with the application router (routes/index.js):

import { Router } from 'express'
import todoRoutes from './todo.routes.js'
import settingsRoutes from './settings.routes.js'

const router = Router()

router.use('/todo', todoRoutes)
router.use('/settings', settingsRoutes)

export default router

Router for settings (routes/settings.routes.js):

import { Router } from 'express'
import { prisma } from '../index.js'

const router = Router()

// получение настроек
router.get("https://habr.com/", async (req, res, next) => {
  try {
    const settings = await prisma.settings.findFirst()
    res.status(200).json(settings)
  } catch (e) {
    next(e)
  }
})

// обновление настроек
router.put('/:id', async (req, res, next) => {
  const id = Number(req.params.id)
  try {
    const settings = await prisma.settings.update({
      data: req.body,
      where: { id }
    })
    res.status(201).json(settings)
  } catch (e) {
    next(e)
  }
})

export default router

Router for tasks (routes/todo.routes.js):

import { Router } from 'express'
import { prisma } from '../index.js'

const router = Router()

// получение задач
router.get("https://habr.com/", async (req, res, next) => {
  try {
    const todos = (await prisma.todo.findMany()).sort(
      (a, b) => a.created_at - b.created_at
    )
    res.status(200).json(todos)
  } catch (e) {
    next(e)
  }
})

// создание задачи
router.post("https://habr.com/", async (req, res, next) => {
  try {
    const newTodo = await prisma.todo.create({
      data: req.body
    })
    res.status(201).json(newTodo)
  } catch (e) {
    next(e)
  }
})

// обновление задачи
router.put('/:id', async (req, res, next) => {
  const id = Number(req.params.id)
  try {
    const updatedTodo = await prisma.todo.update({
      data: req.body,
      where: { id }
    })
    res.status(201).json(updatedTodo)
  } catch (e) {
    next(e)
  }
})

// удаление задачи
router.delete('/:id', async (req, res, next) => {
  const id = Number(req.params.id)
  try {
    await prisma.todo.delete({
      where: { id }
    })
    res.sendStatus(201)
  } catch (e) {
    next(e)
  }
})

export default router

This is all that is required from our server.

Admin panel

The structure of the admin panel will be as follows (admin/src):

- components
  - App.vue - основной компонент приложения
  - Settings.vue - компонент для обновления настроек
- index.js

Let’s start with the main component (components/App.vue).

Markup:

<template>
  <div id="app">
    <h1>Admin</h1>
    <!-- Загрузка -->
    <h2 v-if="loading">Loading...</h2>
    <!-- Ошибка -->
    <h3 v-else-if="error" class="error">
      {{ error.message || 'Something went wrong. Try again later' }}
    </h3>
    <!-- Компонент для обновления настроек -->
    <div v-else>
      <h2>Settings</h2>
      <!-- Пропы: настройки, полученные от сервера (из БД), метод для их получения и адрес API -->
      <Settings :settings="settings" :getSettings="getSettings" :apiUri="apiUri" />
    </div>
  </div>
</template>

Styles:

@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');

:root {
  --primary: #0275d8;
  --success: #5cb85c;
  --warning: #f0ad4e;
  --danger: #d9534f;
  --light: #f7f7f7;
  --dark: #292b2c;
}

* {
  font-family: 'Montserrat', sans-serif;
  font-size: 1rem;
}

body.light {
  background-color: var(--light);
  color: var(--dark);
}

body.dark {
  background-color: var(--dark);
  color: var(--light);
}

#app {
  display: flex;
  flex-direction: column;
  text-align: center;
}

h2 {
  font-size: 1.4rem;
}

form div {
  display: flex;
  flex-direction: column;
  align-items: center;
}

label {
  margin: 0.5rem 0;
}

input {
  padding: 0.5rem;
  max-width: 220px;
  width: max-content;
  outline: none;
  border: 1px solid var(--dark);
  border-radius: 4px;
  text-align: center;
}

input:focus {
  border-color: var(--primary);
}

button {
  margin: 1rem 0;
  padding: 0.5rem 1rem;
  background: none;
  border: none;
  border-radius: 4px;
  outline: none;
  background-color: var(--success);
  color: var(--light);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  user-select: none;
  transition: 0.2s;
}

button:active {
  box-shadow: none;
}

.error {
  color: var(--danger);
}

Script:

// импортируем компонент для обновления настроек
import Settings from './Settings'

export default {
  // название компонента
  name: 'App',
  // дочерние компоненты
  components: {
    Settings
  },
  // начальное состояние
  data() {
    return {
      loading: true,
      error: null,
      settings: {},
      apiUri: 'http://localhost:5000/api/settings'
    }
  },
  // монтирование компонента
  created() {
    // получаем настройки
    this.getSettings()
  },
  // методы
  methods: {
    // для получения настроек
    async getSettings() {
      this.loading = true
      try {
        const response = await fetch(API_URI)
        if (!response.ok) throw response
        this.settings = await response.json()
      } catch (e) {
        this.error = e
      } finally {
        this.loading = false
      }
    }
  }
}

Now let’s look at the component for updating the settings (components/Settings.vue).

Markup:

<template>
  <!-- Загрузка -->
  <div v-if="loading">Loading...</div>
  <!-- Ошибка -->
  <div v-else-if="error">
    {{ error.message || JSON.stringify(error, null, 2) }}
  </div>
  <!-- Настройки -->
  <form v-else @submit.prevent="saveSettings">
    <!-- Приветствие -->
    <div>
      <label for="greetings">Greetings</label>
      <input
        type="text"
        id="greetings"
        name="greetings"
        :value="settings.greetings"
        required
      />
    </div>
    <!-- Тема -->
    <div>
      <label for="theme">Theme</label>
      <input
        type="text"
        id="theme"
        name="theme"
        :value="settings.theme"
        required
      />
    </div>
    <!-- Базовый размер шрифта -->
    <div>
      <label for="base_font_size">Base font size</label>
      <input
        type="text"
        id="base_font_size"
        name="base_font_size"
        :value="settings.base_font_size"
        required
      />
    </div>

    <button>Save</button>
  </form>
</template>

Script:

export default {
  // название компонента
  name: 'Settings',
  // пропы
  props: {
    settings: {
      type: Object,
      required: true
    },
    getSettings: {
      type: Function,
      required: true
    },
    apiUri: {
      type: String,
      required: true
    }
  },
  // начальное состояние
  data() {
    return {
      loading: false,
      error: null
    }
  },
  // методы
  methods: {
    // для обновления настроек в БД
    async saveSettings(e) {
      this.loading = true
      const formDataObj = [...new FormData(e.target)].reduce(
        (obj, [key, val]) => ({
          ...obj,
          [key]: val
        }),
        {}
      )
      try {
        const response = await fetch(`${this.apiUri}/${this.settings.id}`, {
          method: 'PUT',
          body: JSON.stringify(formDataObj),
          headers: {
            'Content-Type': 'application/json'
          }
        })
        if (!response.ok) throw response
        // получаем обновленные настройки
        await this.getSettings()
      } catch (e) {
        this.error = e
      } finally {
        this.loading = false
      }
    }
  }
}

This is where we finished with the admin panel.

Client

The client structure will be as follows (client/src):

- api
  - settings.api.js - API для настроек
  - todo.api.js - API для задач
- components
  - TodoForm.js - компонент для создания задачи
  - TodoList.js - компонент для формирования списка задач
- hooks
  - useStore.js - хранилище состояние в виде пользовательского хука
- App.js - основной компонент приложения
- App.css
- index.js

To manage the state of the application, we will use Zustand.

Installing it:

yarn add zustand

Let’s start with the API for settings (api/settings.api.js):

// конечная точка
const API_URI = 'http://localhost:5000/api/settings'

// метод для получения настроек
const fetchSettings = async () => {
  try {
    const response = await fetch(API_URI)
    if (!response.ok) throw response
    return await response.json()
  } catch (e) {
    throw e
  }
}

const settingsApi = { fetchSettings }

export default settingsApi

API for tasks (api/todo.api.js):

// конечная точка
const API_URI = 'http://localhost:5000/api/todo'

// метод для получения задач
const fetchTodos = async () => {
  try {
    const response = await fetch(API_URI)
    if (!response.ok) throw response
    return await response.json()
  } catch (e) {
    throw e
  }
}

// метод для создания новой задачи
const addTodo = async (newTodo) => {
  try {
    const response = await fetch(API_URI, {
      method: 'POST',
      body: JSON.stringify(newTodo),
      headers: {
        'Content-Type': 'application/json'
      }
    })
    if (!response.ok) throw response
    return await response.json()
  } catch (e) {
    throw e
  }
}

// метод для обновления задачи
const updateTodo = async (id, changes) => {
  try {
    const response = await fetch(`${API_URI}/${id}`, {
      method: 'PUT',
      body: JSON.stringify(changes),
      headers: {
        'Content-Type': 'application/json'
      }
    })
    if (!response.ok) throw response
    return await response.json()
  } catch (e) {
    throw e
  }
}

// метод для удаления задачи
const removeTodo = async (id) => {
  try {
    const response = await fetch(`${API_URI}/${id}`, {
      method: 'DELETE'
    })
    if (!response.ok) throw response
  } catch (e) {
    throw e
  }
}

const todoApi = { fetchTodos, addTodo, updateTodo, removeTodo }

export default todoApi

State storage in the form of a custom hook (hooks/useStore.js):

import create from 'zustand'
// API для настроек
import settingsApi from '../api/settings.api'
// API для задач
import todoApi from '../api/todo.api'

const useStore = create((set, get) => ({
  // начальное состояние
  settings: {},
  todos: [],
  loading: false,
  error: null,
  // методы для
  // получения настроек
  fetchSettings() {
    set({ loading: true })
    settingsApi
      .fetchSettings()
      .then((settings) => {
        set({ settings })
      })
      .catch((error) => {
        set({ error })
      })
      .finally(() => {
        set({ loading: false })
      })
  },
  // получения задач
  fetchTodos() {
    set({ loading: true })
    todoApi
      .fetchTodos()
      .then((todos) => {
        set({ todos })
      })
      .catch((error) => {
        set({ error })
      })
      .finally(() => {
        set({ loading: false })
      })
  },
  // создания задачи
  addTodo(newTodo) {
    set({ loading: true })
    todoApi
      .addTodo(newTodo)
      .then((newTodo) => {
        const todos = [...get().todos, newTodo]
        set({ todos })
      })
      .catch((error) => {
        set({ error })
      })
      .finally(() => {
        set({ loading: false })
      })
  },
  // обновления задачи
  updateTodo(id, changes) {
    set({ loading: true })
    todoApi
      .updateTodo(id, changes)
      .then((updatedTodo) => {
        const todos = get().todos.map((todo) =>
          todo.id === updatedTodo.id ? updatedTodo : todo
        )
        set({ todos })
      })
      .catch((error) => {
        set({ error })
      })
      .finally(() => {
        set({ loading: false })
      })
  },
  // удаления задачи
  removeTodo(id) {
    set({ loading: true })
    todoApi
      .removeTodo(id)
      .then(() => {
        const todos = get().todos.filter((todo) => todo.id !== id)
        set({ todos })
      })
      .catch((error) => {
        set({ error })
      })
      .finally(() => {
        set({ loading: false })
      })
  }
}))

export default useStore

Component for creating a new task (components/TodoForm.js):

import { useState, useEffect } from 'react'
import useStore from '../hooks/useStore'

export default function TodoForm() {
  // метод для создания задачи из хранилища
  const addTodo = useStore(({ addTodo }) => addTodo)
  // состояние для текста новой задачи
  const [text, setText] = useState('')
  const [disable, setDisable] = useState(true)

  useEffect(() => {
    setDisable(!text.trim())
  }, [text])

  // метод для обновления текста задачи
  const onChange = ({ target: { value } }) => {
    setText(value)
  }

  // метод для отправки формы
  const onSubmit = (e) => {
    e.preventDefault()
    if (disable) return
    const newTodo = {
      text,
      done: false
    }
    addTodo(newTodo)
  }

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="text">New todo text</label>
      <input type="text" id='text' value={text} onChange={onChange} />
      <button className="add">Add</button>
    </form>
  )
}

Component for forming a list of tasks (components/TodoList.js):

import useStore from '../hooks/useStore'

export default function TodoList() {
  // задачи и методы для обновления и удаления задачи из хранилища
  const { todos, updateTodo, removeTodo } = useStore(
    ({ todos, updateTodo, removeTodo }) => ({ todos, updateTodo, removeTodo })
  )

  return (
    <ul>
      {todos.map(({ id, text, done }) => (
        <li key={id}>
          <input
            type="checkbox"
            checked={done}
            onChange={() => {
              updateTodo(id, { done: !done })
            }}
          />
          <span>{text}</span>
          <button
            onClick={() => {
              removeTodo(id)
            }}
          >
            Remove
          </button>
        </li>
      ))}
    </ul>
  )
}

The main component of the application (App.js):

import { useEffect } from 'react'
import './App.css'
import useStore from './hooks/useStore'
import TodoForm from './components/TodoForm'
import TodoList from './components/TodoList'

// получаем настройки
useStore.getState().fetchSettings()
// получаем задачи
useStore.getState().fetchTodos()

function App() {
  // настройки, индикатор загрузки и ошибка из хранилища
  const { settings, loading, error } = useStore(
    ({ settings, loading, error }) => ({ settings, loading, error })
  )

  useEffect(() => {
    if (Object.keys(settings).length) {
      // применяем базовый размер шрифта к элементу `html`
      document.documentElement.style.fontSize = settings.base_font_size
      // применяем тему
      document.body.className = settings.theme
    }
  }, [settings])

  // загрузка
  if (loading) return <h2>Loading...</h2>

  // ошибка
  if (error)
    return (
      <h3 className="error">
        {error.message || 'Something went wrong. Try again later'}
      </h3>
    )

  return (
    <div className="App">
      <h1>Client</h1>
      <h2>{settings.greetings}</h2>
      <TodoForm />
      <TodoList />
    </div>
  )
}

export default App

Styles (App.css):

@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@200;400;600&display=swap');

:root {
  --primary: #0275d8;
  --success: #5cb85c;
  --warning: #f0ad4e;
  --danger: #d9534f;
  --light: #f7f7f7;
  --dark: #292b2c;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Montserrat', sans-serif;
  font-size: 1rem;
}

/* Тема */
body.light {
  background-color: var(--light);
  color: var(--dark);
}

body.dark {
  background-color: var(--dark);
  color: var(--light);
}
/* --- */

#root {
  padding: 1rem;
  display: flex;
  justify-content: center;
}

.App {
  display: flex;
  flex-direction: column;
  align-items: center;
}

h1,
h2 {
  margin: 1rem 0;
}

h1 {
  font-size: 1.6rem;
}

h2 {
  font-size: 1.4rem;
}

h3 {
  font-size: 1.2rem;
}

label {
  margin-bottom: 0.5rem;
  display: block;
}

form {
  margin: 1rem 0;
}

form input {
  padding: 0.5rem;
  max-width: 220px;
  width: max-content;
  outline: none;
  border: 1px solid var(--dark);
  border-radius: 4px;
  text-align: center;
}

form input:focus {
  border-color: var(--primary);
}

ul {
  list-style: none;
}

li {
  margin: 0.75rem 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

li input {
  width: 18px;
  height: 18px;
}

li span {
  display: block;
  width: 120px;
  word-break: break-all;
}

button {
  padding: 0.5rem 1rem;
  background: none;
  border: none;
  border-radius: 4px;
  outline: none;
  background-color: var(--danger);
  color: var(--light);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  user-select: none;
  transition: 0.2s;
}

button:active {
  box-shadow: none;
}

button.add {
  background-color: var(--success);
}

.error {
  color: var(--danger);
}

.App {
  text-align: center;
}

Since the margins and sizes are set using rem, we can easily manipulate these values by changing the font size of the element html.

We are also done with the client on this.

Application health check

We go up to the root directory (docker-test), initialize Node.js-проект and install concurrently – utility for simultaneous execution of commands defined in the file package.json:

# мы находимся в директории `docker-test`
yarn init -yp
yarn add concurrently

We define commands to run servers for development in package.json:

"scripts": {
  "dev:client": "yarn --cwd services/client start",
  "dev:admin": "yarn --cwd services/admin dev",
  "dev:api": "yarn --cwd services/api dev",
  "dev": "concurrently "yarn dev:client" "yarn dev:admin" "yarn dev:api""
}

Execute the command yarn dev or npm run dev.

This leads to the launch of 3 servers for development:

  • for the client at http://localhost:3000:

  • for the admin panel at http://localhost:4000“:

  • for the server at http://localhost:5000.

Changing the settings in the admin panel:

Rebooting the client:

We see that the settings have been successfully applied.

Working with tasks:

Tasks are successfully created/updated/deleted and saved in the database.

Excellent. The app works as expected.

Perhaps that’s all I wanted to tell you about in this part.

Thank you for your attention and happy coding!

Ready to see us in action:

More To Explore

IWanta.tech
Logo
Enable registration in settings - general
Have any project in mind?

Contact us:

small_c_popup.png