Frontend developement on vue and daisyui (tailwind css) for backend developer

Замысел и ограничения

Для пет-проекта chipa решил я сделать админку посимпатичнее, заодно окунуться в современный фронтенд и вспомнить vuejs + typescript.

Я не дизайнер и не фронтенд-разработчик, потому к разработке фронтенд подход был следующий:

  1. Для админки дизайн не нужен: достаточно симпатичного современного стиля, чтобы глаз не резало и пользователю было удобно решать свою задачу. P.S. речь конечно про MVP или пет-проект. Крутой админке дизайн нужен :)
  2. Нужно опираться на какую-то достаточно высокую фронтенд-абстракцию, где решены вопросы стиля, и для разработки достаточно разместить нужные компоненты на странице (избежать работы с css, с выбором цветов, layout, с отступами на странице и т.п.)
  3. В opensource достаточно кодовой базы, над которой потрудились дизайнеры (в виде библиотек, шаблонов): можно найти нужный мне уровень абстрации.

Уровень абстрации и daisyui

Если идти по уровням абстрации разработки фронтенд снизу вверх, то будет так:

  1. Чистый CSS
  2. Фреймворк над CSS, упрощающий работу со стилями. Один из самых популярных - tailwind css. На нем я сделал первую версию админки. Получилось очень быстро, но не очень красиво (посмотреть можно в первой технической демке »)
  3. Фреймворк компонентов над tailwind css - много их. Я выбрал самый популярный - daisyui

Daisyui дал мне ощущение создания фронтенда комбинированием кубиков (компонентов), чего я и добивался как бекенд-разработчик желающий создать симпатичный фронтенд минимальными усилиями, без навыков frontend.

Реализация

vue

В приложении chipa самое главное - форма редактирования бота. form

Основные задачи которые решает Vue:

  1. Декомпозиция сложной формы на простые компоненты.
  2. Логика работы компонентов (реактивное отображение состояния)

Например, так вылдядит код компонента описания бота, source »:

<script setup lang="ts">
import BotDescription from './composables/BotDescription.vue';
import CsvUpload from './composables/CsvUpload.vue'
import BotChat from './composables/BotChat.vue'
import BotTelegram from './composables/BotTelegram.vue';

import { onBeforeMount } from 'vue';
import { useBotStore } from '@/stores/currentBot';

const botStore = useBotStore()

onBeforeMount(()=>{
    console.debug('EditBot component before mount')
})
</script>

<template>
    <div id="edit-bot-ctr" class="mt-6 space-y-6">
        <BotDescription />
        <div v-if="botStore.bot!.id">
            <div class="divider">csv</div> 
            <!-- <CsvUpload v-if="!botStore.bot!.csvSaved" /> -->
            <CsvUpload />
            <div v-if="botStore.bot!.csvSaved">
                <!-- <CsvReupload/> -->
                <div class="divider">testing (chat)</div>
                <BotChat/>
                <div class="divider">telegram integration</div>
                <BotTelegram/>
            </div>
        </div>
    </div>
</template>

BotDescription, CsvUpload и т.п. - это компоненты вкладки редактирования бота, каждый с понятной единой ответственностью.


Как видно, код компонентов тривиальный. Меньше сложность = меньше багов + быстрее разработка + счастливее разработчик!

state, pinia

Вопрос, который возникает при декомпозиции формы на компоненты: как организовать модель компонентов. Пользователь редактирует одного бота, но конкретный кусок информации бота редактируется в компоненте.

В общем случае, состояние компонентов Vue можно задавать через свойства (props), но тогда, если дочерний компонент меняет свойство, и об этом должны узнать другие компоненты (например, родительский), то дочерний должен отправить событие. Сложно для моего случая.

Наиболее простым вариантом я счел использование глобального состояния, которое хранится в pinia store:

state

Код компонента редактирования основной информации бота при этом выглядит так, full source »:

<script setup lang="ts">
import router from '@/router';
import { useBotStore } from '@/stores/currentBot';

const botStore = useBotStore()

...
</script>

<template>
    <div id="bot-description-ctr" class="space-y-6">
        <div id="bot-info" className="grid grid-cols-1 md:grid-cols-2 gap-6">
            <!-- <div class="form-control w-full max-w-xs"> -->
            <div class="form-control w-full">
                <label class="label">
                    <span class="label-text">Name</span>
                </label>
                <input v-model="botStore.bot!.name" type="text" placeholder="Type name here"
                    class="input input-bordered w-full" />
            </div>
...
    </div>
</template>

Обратите внимание на botStore и input v-model=“botStore.bot!.name”: так для input element устанавливается редактирование поля бота (name).

Бот устанавливается в store при открытии формы редактирования бота, source »:

onBeforeMount(() => {
    botStore.bot = undefined
    if (isNewBot()) {
        console.info("Initializing new bot...")
        initNewBot()
        console.info("New bot initialized")
    } else {
        console.info(`Loading bot; id=${botId}...`)

        fetch('/api/bots/' + botId, {headers: authHeaders()})
            .then((response) => {
                if (response.ok) {
                    return response.json();
                }
                throw new Error(response.statusText);
            })
            .then((data: Bot) => {
                botStore.bot = data
                console.info(`Bot loaded {id: ${data.id}, name: ${data.name}}`)
            })
            .catch((err) => {
                console.error(err)
                router.go(0)
            })
    }
})

daisyui

Демонстрация мощи daisyui.

  1. Layout приложения выглядит так:
    <div class="drawer">
        <div class="drawer-content flex flex-col bg-base-200">
            <Navbar />
            <!-- Page content here -->
            <RouterView />
        </div>
    </div>

Здесь используется компонент drawer

  1. Карточки ботов, source »
    <div class="card bg-base-100">
        <div class="card-body items-center text-center">
            <div class="flex gap-4 flex-wrap overflow-x-auto">
                <BotCardParrot />
                <h2 class="card-title max-w-xs whitespace-nowrap">{{ bot.name }}</h2>
            </div>
            <div class="badge badge-outline badge-ghost">{{ lang(bot.language) }}</div>
            <p class="">{{ bot.description }}</p>
            <div class="card-actions justify-end">
                <RouterLink :to="`/bots/${bot.id}/info`">
                    <button class="btn btn-primary">Edit</button>
                </RouterLink>
                <RouterLink :to="`/bots/${bot.id}/stats`">
                    <button v-if="bot.csvSaved" class="btn btn-primary">Stats</button>
                </RouterLink>
            </div>
        </div>
    </div>

Здесь используется компонент card »

Результат:

daisy

tabs, vue router

Вкладки реализованы с использованием Vue Router.

Форма бота выглядит так, source »:

<template>
    <div id="form-card" class="card w-full lg:w-2/3 bg-base-100 shadow-xl p-6 space-y-6">
        <h2 class="card-title">Bot</h2>

        <!-- tab border full width: https://stackoverflow.com/a/76161483/827704 -->
        <div id="tabs" v-if="botStore.bot!.id">
            <div
                class="tabs bg-[linear-gradient(theme(colors.base-300),theme(colors.base-300))] bg-bottom bg-no-repeat bg-[length:100%_1px]">
                <RouterLink :to="{ name: 'editBot' }" class="tab tab-lifted"
                    :class="route.name?.toString() == 'editBot' ? 'tab-active' : ''">Edit bot</RouterLink>
                <RouterLink v-if="botStore.bot!.csvSaved" :to="{ name: 'botApi' }" class="tab tab-lifted"
                    :class="route.name?.toString() == 'botApi' ? 'tab-active' : ''">API</RouterLink>
                <RouterLink v-if="botStore.bot!.csvSaved" :to="{ name: 'botStats' }" class="tab tab-lifted"
                    :class="route.name?.toString() == 'botStats' ? 'tab-active' : ''">Stats</RouterLink>
            </div>
            <RouterView />
        </div>
    </div>
</template>

Каждая кнопка вкладки является RouterLink, а контент вкладки отображается в RouterView. Конкретный компонент, который отображается в RouterView задан в конфиге роутера, source »:

    {
      path: '/bots/:id',
      name: 'bot',
      component: BotView,
      children: [
        {
          path: '',
          name: 'editBotHome',
          redirect: { name: 'editBot' },
        },
        {
          name: 'editBot',
          path: 'info',
          component: EditBot,
        },
        {
          name: 'botApi',
          path: 'api',
          component: BotApi,
        },
        {
          name: 'botStats',
          path: 'stats',
          component: BotStats,
        },
      ],
    },

Благодаря глобальному state (см. раздел pinia), каждая вкладка открывает компонент с редактируемым в настоящий момент ботом.

json-server, vite = developer joy

Developer joy один из принципов проекта chipa ».

Он соблюдается в таких вещак как:

  1. Quarkus dev mode
  2. Simple SOLID microservices (see chipa nlp services)
  3. Using temporal to bind microservices

Демонстрация workflow разработки frontend в экосистеме Vite, Vue + json server:

keycloak

Для security приложения используется keycloak. Keycloak предоставляет библиотеку keycloak-js.

keycloak сохранен в pinia store.

Пример вызова защищенного API

opensource

Проект выложен на https://github.com/achernetsov/chipa-admin2