Docker: заметки веб-разработчика. Итерация третья

Kate

Administrator
Команда форума
В этой части мы разработаем простое приложение, состоящее из трех сервисов и базы данных, а в заключительной — "контейнеризуем" его.


Репозиторий с кодом приложения.


Если вам это интересно, прошу под кат.

Подготовка и настройка проекта​


Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвященные работе с Docker. Впрочем, в этой части Docker будет совсем чуть-чуть.


Также предполагается, что на вашей машине установлен Docker и Node.js.


Хорошо, если на вашей машине установлен Yarn и вы имеете опыт работы с React, Vue, Node.js, PostgreSQL и sh или bash (все это опционально).


Как я сказал, наше приложение будет состоять из трех сервисов:


  • клиента на React.js;
  • админки на Vue.js;
  • сервера (API) на Node.js.

В качестве базы данных мы будем использовать PostgreSQL, а для взаимодействия с ней — Prisma.


Функционал нашего приложения будет следующим:


  • в админке задаются настройки для приветствия, темы и базового размера шрифта;
  • эти настройки записываются в БД и применяются на клиенте;
  • на клиенте реализована "тудушка";
  • задачи записываются в БД;
  • все это обслуживается сервером.

Создаем директорию для проекта, переходим в нее и создаем еще парочку директорий:


mkdir docker-test

cd !$ # docker-test

mkdir services sh uploads

В директории services будут находиться наши сервисы, в директории sh — скрипты для терминала, директорию uploads мы использовать не будем, но обычно в ней хранятся различные файлы, загружаемые админом или пользователями.


Переходим в директорию services, создаем директорию для API, генерируем шаблон клиента с помощью Create React App и шаблон админки с помощью 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

Начнем с API.


API​


Переходим в директорию api, инициализируем Node.js-проект и устанавливаем зависимости:


cd api

yarn init -yp
# or
npm init -y

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

  • express — Node.js-фреймворк для разработки веб-серверов;
  • cors — утилита для работы с CORS;
  • nodemon — утилита для запуска сервера для разработки;
  • prisma — ядро (core) ORM, которое мы будем использовать для взаимодействия с postgres.

Инициализируем prisma:


npx prisma init

Это приводит к генерации директории prisma, а также файлов prisma/schema.prisma и .env.


Определяем генератор, источник данных и модели в файле 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
}

Определяем путь к БД в файле .env:


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

Здесь:


  • postgres — имя пользователя и пароль;
  • localhost — хост, на котором запущен сервер postgres;
  • 5432 — порт, на котором запущен сервер postgres;
  • mydb — название БД.

Определяем команду для запуска контейнера postgres в файле sh/db (без расширения):


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

Обратите внимание: если вы работаете на Mac, вам потребуется предоставить самому себе разрешение на выполнение кода из файла sh/db. Это можно сделать так:


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

Находясь в корневой директории проекта, открываем терминал и выполняем команду:


sh/db

Происходит загрузка образа postgres из Docker Hub и запуск контейнера под названием postgres.


Обратите внимание: иногда может возникнуть ошибка, связанная с тем, что порт 5432 занят другим процессом. В этом случае необходимо найти PID данного процесса и "убить" его. На Mac это делается так:


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

Убедиться в запуске контейнера можно, выполнив команду docker ps:


qzcbjnikl7acjtp3u90sd1xcf2o.png




Или запустив Docker Desktop:


xrzun9hxa_zlwwvueumwjzyzq98.png




Или в разделе Individual Containers расширения Docker для VSCode:


1le096w7kcbze_0outprie0ua1g.png




Выполняем миграцию:


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

Это приводит к генерации файла prisma/migrations/[Date]-init/migration.sql, подключению к БД, созданию в ней таблиц, установке и настройке @prisma/client.


Создаем файл prisma/seed.js с кодом для заполнения БД начальными данными:


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()

В package.json определяем тип кода сервера (модуль), команды для запуска сервера в режиме для разработки и производственном режиме, а также команду для заполнения БД начальными данными:


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

Заполняем БД начальными данными:


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

Открываем нашу БД в интерактивном режиме:


npx prisma studio

Это приводит к открытию вкладки браузера по адресу http://localhost:5555:


6-ary1tso3uyjb0imkqlwg4aw94.png




Приступаем к разработке сервера.


Структура сервера будет следующей:


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

Содержание файла 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 🚀 `)
})

Рассмотрим роуты.


Начнем с роутера приложения (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

Роутер для настроек (routes/settings.routes.js):


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

const router = Router()

// получение настроек
router.get('/', 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

Роутер для задач (routes/todo.routes.js):


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

const router = Router()

// получение задач
router.get('/', 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('/', 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

Это все, что требуется от нашего сервера.


Админка​


Структура админки будет следующей (admin/src):


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

Начнем с основного компонента (components/App.vue).


Разметка:


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

Стили:


@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);
}

Скрипт:


// импортируем компонент для обновления настроек
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
}
}
}
}

Теперь рассмотрим компонент для обновления настроек (components/Settings.vue).


Разметка:


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

Скрипт:


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

На этом с админкой мы закончили.


Клиент​


Структура клиента будет следующей (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

Для управления состоянием приложения мы будем использовать Zustand.


Устанавливаем его:


yarn add zustand

Начнем с API для настроек (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 для задач (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

Хранилище состояния в виде пользовательского хука (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

Компонент для создания новой задачи (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>
)
}

Компонент для формирования списка задач (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>
)
}

Основной компонент приложения (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

Стили (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;
}

Поскольку отступы и размеры заданы с помощью rem, мы легко можем манипулировать этими значениями, меняя размер шрифта элемента html.


На этом с клиентом мы также закончили.


Проверка работоспособности приложения​


Поднимаемся в корневую директорию (docker-test), инициализируем Node.js-проект и устанавливаем concurrently — утилиту для одновременного выполнения команд, определенных в файле package.json:


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

Определяем команды для запуска серверов для разработки в 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\""
}

Выполняем команду yarn dev или npm run dev.


Это приводит к запуску 3 серверов для разработки:



vejd5qrgzgbzll2pyb0jxpg0ucw.png





tzuy7-muwq_lxdant4hgpnfemjs.png





Меняем настройки в админке:


kp63wu9r_cdnd-cyqwc9-p2dle0.png




Перезагружаем клиента:


85fycwhapup8y_auvgrldgwke1m.png




Видим, что настройки успешно применились.


Работаем с задачами:


swyrncbic8rxv1s0rxeczhg1bhk.png




Задачи успешно создаются/обновляются/удаляются и сохраняются в БД.


Отлично. Приложение работает, как ожидается.


Пожалуй, это все, о чем я хотел вам рассказать в этой части.


Благодарю за внимание и happy coding!

 
Сверху