Как стать автором
Обновить
1992.48
Timeweb Cloud
То самое облако

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

Время на прочтение15 мин
Количество просмотров13K


Привет, друзья! Продолжаю делиться с вами заметками о Docker.


Заметки состоят из 4 частей: 2 теоретических и 2 практических. Если быть более конкретным:



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


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


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


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


Предполагается, что вы хотя бы вкратце ознакомились с содержанием предыдущих частей или изучали другие материалы, посвященные работе с 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

  • expressNode.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:





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





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





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


# мы находимся в директории `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:





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


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


- 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 серверов для разработки:


  • для клиента по адресу http://localhost:3000:




  • для админки по адресу http://localhost:4000":




  • для сервера по адресу http://localhost:5000.

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





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





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


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





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


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


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


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




Теги:
Хабы:
Всего голосов 16: ↑13 и ↓3+15
Комментарии7

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud

Истории