Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+53
View File
@@ -0,0 +1,53 @@
import { BookOpenText, Columns3Cog, FlaskConical, GitPullRequestArrow, PackagePlus, SquareMousePointer, TextCursorInput } from 'lucide-react';
import { type IntlShape } from 'react-intl';
import { type MenuItem } from '#app/config/types';
export const createDevtoolsMenu = (intl: IntlShape): MenuItem => ({
title: intl.formatMessage({ id: 'DEVTOOLS.TITLE' }),
icon: Columns3Cog,
children: [
{
title: intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE'
}),
path: '/devtools/module-generator',
icon: PackagePlus,
children: [
{
title: intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE'
}),
path: '/devtools/module-generator',
icon: PackagePlus
},
{
title: intl.formatMessage({ id: 'DEVTOOLS.DOCUMENTATION' }),
path: '/devtools/module-generator/docs',
icon: BookOpenText
},
{
title: intl.formatMessage({ id: 'DEVTOOLS.VERSION_MANAGER' }),
path: '/devtools/module-generator/version',
icon: GitPullRequestArrow
}
]
},
{
title: intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.TITLE' }),
path: '/devtools/sandbox',
icon: FlaskConical,
children: [
{
title: intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.UI' }),
path: '/devtools/sandbox/ui',
icon: SquareMousePointer
},
{
title: intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.FORM_CONTROLS' }),
path: '/devtools/sandbox/forms',
icon: TextCursorInput
}
]
}
]
});
+10
View File
@@ -0,0 +1,10 @@
import enMessages from './translations/en.json';
import ukMessages from './translations/uk.json';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ModuleMessages = Record<string, Record<string, any>>;
export const devtoolsMessages: ModuleMessages = {
en: enMessages,
uk: ukMessages
};
+15
View File
@@ -0,0 +1,15 @@
import { IS_DEV_MODE } from '#app/config/app';
import type { AppModuleConfig } from '#app/services/modules/types';
import { createDevtoolsMenu } from './menu';
import { devtoolsMessages } from './messages';
import { devtoolsDisabledRoute, devtoolsRoute } from './routing';
export default {
id: 'devtools',
area: 'devtools',
routeScope: 'protected',
order: 900,
routes: IS_DEV_MODE ? devtoolsRoute : devtoolsDisabledRoute,
menu: IS_DEV_MODE ? createDevtoolsMenu : undefined,
messages: IS_DEV_MODE ? devtoolsMessages : undefined
} satisfies AppModuleConfig;
+71
View File
@@ -0,0 +1,71 @@
import { Navigate, type RouteObject } from 'react-router-dom';
import ModuleGeneratorDocsPage from '../pages/ModuleGeneratorDocsPage';
import ModuleGeneratorPage from '../pages/ModuleGeneratorPage';
import SandboxFormsPage from '../pages/SandboxFormsPage';
import SandboxUIPage from '../pages/SandboxUIPage';
import VersionManagerPage from '../pages/VersionManagerPage';
import DevtoolsLayout from '../view/layouts/DevtoolsLayout';
import SandboxLayout from '../view/layouts/SandboxLayout';
export const devtoolsDisabledRoute: RouteObject = {
path: '/devtools/*',
element: <Navigate to="/error/404" replace />
};
export const devtoolsRoute: RouteObject = {
path: '/devtools',
element: <DevtoolsLayout />,
children: [
{
index: true,
element: <Navigate to="/devtools/module-generator" replace />
},
{
path: '/devtools/module-generator',
children: [
{
index: true,
element: <ModuleGeneratorPage />
},
{
path: 'docs',
element: <ModuleGeneratorDocsPage />
},
{
path: 'version',
element: <VersionManagerPage />
},
{
path: '*',
element: <Navigate to="/devtools/module-generator" replace />
}
]
},
{
path: '/devtools/sandbox',
element: <SandboxLayout />,
children: [
{
index: true,
element: <Navigate to="/devtools/sandbox/ui" replace />
},
{
path: '/devtools/sandbox/ui',
element: <SandboxUIPage />
},
{
path: '/devtools/sandbox/forms',
element: <SandboxFormsPage />
}
]
},
{
path: '/devtools/docs/*',
element: <Navigate to="/devtools/module-generator/docs" replace />
},
{
path: '/devtools/version-manager',
element: <Navigate to="/devtools/module-generator/version" replace />
}
]
};
+128
View File
@@ -0,0 +1,128 @@
{
"DEVTOOLS.TITLE": "Devtools",
"DEVTOOLS.OTHER": "Other",
"DEVTOOLS.SANDBOX.TITLE": "Sandbox",
"DEVTOOLS.SANDBOX.UI": "UI",
"DEVTOOLS.SANDBOX.FORM_CONTROLS": "Form controls",
"DEVTOOLS.SANDBOX.DESCRIPTION": "A visual reference for shared UI components. Each example documents when a variant is appropriate and shows its rendered state.",
"DEVTOOLS.SANDBOX.PLAYGROUND_TITLE": "Component playground",
"DEVTOOLS.SANDBOX.PLAYGROUND_DESCRIPTION": "Compare shared UI variants in one place.",
"DEVTOOLS.SANDBOX.OPEN": "Open sandbox",
"DEVTOOLS.SANDBOX.USAGE": "Usage",
"DEVTOOLS.SANDBOX.USAGE_DESCRIPTION": "Import the component and adapt this example to your use case.",
"DEVTOOLS.SUCCESS": "Успіх",
"DEVTOOLS.MODULE_OVERVIEW": "Module Overview",
"DEVTOOLS.DONT_KNOW_WHERE_TO_START": "Don't know where to start?",
"DEVTOOLS.START_WITH_YOURSELF": "Start with yourself!",
"DEVTOOLS.HAVE_YOU_READ_IT": "Have you read it?",
"DEVTOOLS.HURRY_UP_TO_WORK": "Hurry up and work!",
"DEVTOOLS.DOCS": "Docs",
"DEVTOOLS.DOCUMENTATION": "Documentation",
"DEVTOOLS.TUTORIAL": "Tutorial",
"DEVTOOLS.SETTINGS": "Settings",
"DEVTOOLS.UPDATED_THE_VERSION": "Updated the version?",
"DEVTOOLS.DATA_FETCHING_AND_CACHING": "Data Fetching & Caching",
"DEVTOOLS.MODULE_GENERATOR_DOCS": "Module Generator Docs",
"DEVTOOLS.MODULE_STRUCTURE": "Module Structure",
"DEVTOOLS.VERSION_MANAGER": "Version manager",
"DEVTOOLS.VERSION_MANAGER.CURRENT_VERSION": "Current Version",
"DEVTOOLS.VERSION_MANAGER.UPDATE_AVAILABLE": "Update Available",
"DEVTOOLS.VERSION_MANAGER.UPDATE_AVAILABLE_MESSAGE": "A new version is available for your devtools",
"DEVTOOLS.VERSION_MANAGER.LATEST_VERSION": "Latest Version",
"DEVTOOLS.VERSION_MANAGER.LAST_UPDATED_DATE": "Last updated: {date}",
"DEVTOOLS.VERSION_MANAGER.RELEASE_TYPE": "Release Type",
"DEVTOOLS.VERSION_MANAGER.SYSTEM_INFORMATION": "System Information",
"DEVTOOLS.VERSION_MANAGER.INSTALLATION_PATH": "Installation Path",
"DEVTOOLS.VERSION_MANAGER.ENVIRONMENT": "Environment",
"DEVTOOLS.VERSION_MANAGER.LAST_CHECK": "Last Check",
"DEVTOOLS.VERSION_MANAGER.UPDATE_DEVTOOLS": "Update DevTools",
"DEVTOOLS.VERSION_MANAGER.UPDATE_TO_VERSION": "Update to {version}",
"DEVTOOLS.VERSION_MANAGER.UPDATE_DEVTOOLS_MESSAGE": "Click the button below to install the latest version of devtools",
"DEVTOOLS.VERSION_MANAGER.MODULE_SUCCESSFULLY_UPDATED": "Module successfully updated",
"DEVTOOLS.MODULES.TITLE": "Modules",
"DEVTOOLS.MODULES.FORM_PREVIEW": "Form preview",
"DEVTOOLS.MODULES.FORM_PREVIEW_DESCRIPTION": "This is how your form will look to users",
"DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE": "Module Generator",
"DEVTOOLS.MODULES.MODULE_NAME": "Module Name",
"DEVTOOLS.MODULES.ENTER_MODULE_NAME": "Enter module name",
"DEVTOOLS.MODULES.TABLE_NAME": "Table Name",
"DEVTOOLS.MODULES.SELECT_TABLE": "Select table",
"DEVTOOLS.MODULES.INCLUDE_IN_MODULE": "Include in Module",
"DEVTOOLS.MODULES.INCLUDE_IN_FORM": "Include in Form",
"DEVTOOLS.MODULES.SHOW_IN_DATATABLE": "Show in DataTable",
"DEVTOOLS.MODULES.COLUMN_TYPE": "Column Type",
"DEVTOOLS.MODULES.COLUMN": "Column",
"DEVTOOLS.MODULES.DIALOG": "Dialog",
"DEVTOOLS.MODULES.DIALOG_SIZE": "Dialog size: {children}",
"DEVTOOLS.MODULES.SHEET": "Sheet",
"DEVTOOLS.MODULES.SHEET_SIDE.TOP": "Top",
"DEVTOOLS.MODULES.SHEET_SIDE.RIGHT": "Right",
"DEVTOOLS.MODULES.SHEET_SIDE.BOTTOM": "Bottom",
"DEVTOOLS.MODULES.SHEET_SIDE.LEFT": "Left",
"DEVTOOLS.MODULES.ADD_COLUMN": "Add Column",
"DEVTOOLS.MODULES.REMOVE_COLUMN": "Remove column",
"DEVTOOLS.MODULES.SELECT_COLUMN": "Select column",
"DEVTOOLS.MODULES.CONFIGURE_FORM_FIELD": "Configure Form Field",
"DEVTOOLS.MODULES.EDIT_LABEL": "Edit label",
"DEVTOOLS.MODULES.MODULE_METADATA": "Module Metadata",
"DEVTOOLS.MODULES.MODULE_FORM": "Module Form",
"DEVTOOLS.MODULES.FORM": "Form",
"DEVTOOLS.MODULES.FIELD_TYPE": "Field Type",
"DEVTOOLS.MODULES.SELECT_FIELD_TYPE": "Select field type",
"DEVTOOLS.MODULES.DATATABLE": "Data Table",
"DEVTOOLS.MODULES.MANUAL.OPEN_MANUAL": "Open Manual",
"DEVTOOLS.MODULES.MANUAL.DEVTOOLS_MANUAL": "Devtools Manual",
"DEVTOOLS.MODULES.MANUAL.MANUAL_FOR_MODULE_CREATION": "Manual for module creation",
"DEVTOOLS.MODULES.MANUAL.MANUAL_FOR_MODULE_NAME_DESC": "Module name should be only singular noun or nouns in {children}",
"DEVTOOLS.MODULES.MANUAL.RIGHT": "Right",
"DEVTOOLS.MODULES.MANUAL.WRONG": "Wrong",
"DEVTOOLS.MODULES.MANUAL.BADGE": "Badge",
"DEVTOOLS.MODULES.MANUAL.ADD_BADGE": "Add Badge",
"DEVTOOLS.MODULES.MANUAL.ADD_SORTING": "Add Sorting",
"DEVTOOLS.MODULES.MANUAL.ADD_SORTING_DESC": "This will add sorting to current column",
"DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON": "Add Copy to clipboard button",
"DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON_DESC": "This will add clipboard button to current column next to content on cell",
"DEVTOOLS.MODULES.MANUAL.ADD_BADGE_DESC": "This will wrap column data in Badge component",
"DEVTOOLS.MODULES.MANUAL.IMPORTANT": "Important",
"DEVTOOLS.MODULES.MANUAL.IMPORTANT_MESSAGE": "Some configuration options may override others",
"DEVTOOLS.MODULES.MANUAL.BADGE_VARIANT": "Badge variant",
"DEVTOOLS.MODULES.MANUAL.MEANING": "Meaning",
"DEVTOOLS.MODULES.MANUAL.BADGE_DESC": "Legend for badge variants used in the generator module",
"DEVTOOLS.MODULES.ERROR.MERGE_FILLED_CELLS": "You can't merge filled cells",
"DEVTOOLS.MODULES.ERROR.ONLY_HORIZONTAL_MERGE_ALLOWED_MESSAGE": "Only horizontal merging is allowed. Please select cells from the same row",
"DEVTOOLS.MODULES.ERROR.ONLY_CONSECUTIVE_MERGE_ALLOWED_MESSAGE": "Please select consecutive cells in the same row",
"DEVTOOLS.MODULES.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
"DEVTOOLS.MODULES.ERROR.SELECT_MERGED_CELL_TO_SPLIT": "Please select a merged cell to split",
"DEVTOOLS.MODULES.ERROR.SELECT_TWO_CELLS_TO_SWAP": "Select two cells to perform a swap",
"DEVTOOLS.MODULES.ERROR.CANT_SWAP_EMPTY_CELLS": "Can not swap empty cells",
"DEVTOOLS.MODULES.LABEL": "Label",
"DEVTOOLS.MODULES.ENTER_FIELD_LABEL": "Enter field label",
"DEVTOOLS.MODULES.PLACEHOLDER": "Placeholder",
"DEVTOOLS.MODULES.ENTER_FIELD_PLACEHOLDER": "Enter placeholder text",
"DEVTOOLS.MODULES.ENTER_OPTION_LABEL": "Enter option label",
"DEVTOOLS.MODULES.ENTER_OPTION_VALUE": "Enter option value",
"DEVTOOLS.MODULES.ENTER_OPTION_GROUP": "Group (optional)",
"DEVTOOLS.MODULES.REQUIRED_FIELD": "Required field",
"DEVTOOLS.MODULES.ADD_RED_STAR_MESSAGE": "Add red star {children} to field label",
"DEVTOOLS.MODULES.CLICK_TO_ADD_FIELD": "Click to add field",
"DEVTOOLS.MODULES.UNTITLED_FIELD": "Untitled Field",
"DEVTOOLS.MODULES.MODULE_SUCCESSFULLY_GENERATED": "Module successfully generated",
"DEVTOOLS.ACTION.CONFIRM": "Confirm",
"DEVTOOLS.ACTION.CANCEL": "Cancel",
"DEVTOOLS.ACTION.DELETE": "Delete",
"DEVTOOLS.ACTION.REMOVE": "Remove",
"DEVTOOLS.ACTION.EDIT": "Edit",
"DEVTOOLS.ACTION.SEARCH": "Search",
"DEVTOOLS.ACTION.SWAP": "Swap",
"DEVTOOLS.ACTION.MERGE": "Merge",
"DEVTOOLS.ACTION.SPLIT": "Split",
"DEVTOOLS.ACTION.RESET": "Reset",
"DEVTOOLS.ACTION.SUBMIT": "Submit",
"DEVTOOLS.ACTION.SAVE": "Save",
"DEVTOOLS.ACTION.GENERATE": "Generate",
"DEVTOOLS.ACTION.PREVIEW": "Preview",
"DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
"DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
"DEVTOOLS.STATE.GENERATING": "Generating",
"DEVTOOLS.STATE.UPDATING": "Updating"
}
+128
View File
@@ -0,0 +1,128 @@
{
"DEVTOOLS.TITLE": "Інструменти розробки",
"DEVTOOLS.OTHER": "Інше",
"DEVTOOLS.SANDBOX.TITLE": "Пісочниця",
"DEVTOOLS.SANDBOX.UI": "Інтерфейс",
"DEVTOOLS.SANDBOX.FORM_CONTROLS": "Елементи форми",
"DEVTOOLS.SANDBOX.DESCRIPTION": "Візуальний довідник спільних компонентів інтерфейсу. Кожен приклад пояснює призначення варіанта та показує його вигляд.",
"DEVTOOLS.SANDBOX.PLAYGROUND_TITLE": "Майданчик компонентів",
"DEVTOOLS.SANDBOX.PLAYGROUND_DESCRIPTION": "Порівнюйте варіанти спільних компонентів інтерфейсу в одному місці.",
"DEVTOOLS.SANDBOX.OPEN": "Відкрити пісочницю",
"DEVTOOLS.SANDBOX.USAGE": "Використання",
"DEVTOOLS.SANDBOX.USAGE_DESCRIPTION": "Імпортуйте компонент і адаптуйте цей приклад до свого сценарію.",
"DEVTOOLS.SUCCESS": "Success",
"DEVTOOLS.MODULE_OVERVIEW": "Огляд модуля",
"DEVTOOLS.DONT_KNOW_WHERE_TO_START": "Не знаєш з чого почати?",
"DEVTOOLS.START_WITH_YOURSELF": "Почни з себе!",
"DEVTOOLS.HAVE_YOU_READ_IT": "Прочитав?",
"DEVTOOLS.HURRY_UP_TO_WORK": "Іди працюй!",
"DEVTOOLS.DOCS": "Доки",
"DEVTOOLS.DOCUMENTATION": "Документація",
"DEVTOOLS.TUTORIAL": "Інструкція",
"DEVTOOLS.SETTINGS": "Налаштування",
"DEVTOOLS.UPDATED_THE_VERSION": "Оновив Версію?",
"DEVTOOLS.DATA_FETCHING_AND_CACHING": "Отримання даних та кешування",
"DEVTOOLS.MODULE_GENERATOR_DOCS": "Доки по генерації модуля",
"DEVTOOLS.MODULE_STRUCTURE": "Структура модуля",
"DEVTOOLS.VERSION_MANAGER": "Менеджер версій",
"DEVTOOLS.VERSION_MANAGER.CURRENT_VERSION": "Поточна Версія",
"DEVTOOLS.VERSION_MANAGER.UPDATE_AVAILABLE": "Доступне Оновлення",
"DEVTOOLS.VERSION_MANAGER.UPDATE_AVAILABLE_MESSAGE": "Для ваших інструментів розробки доступна нова версія",
"DEVTOOLS.VERSION_MANAGER.LATEST_VERSION": "Остання Версія",
"DEVTOOLS.VERSION_MANAGER.LAST_UPDATED_DATE": "Останнє оновлення: {date}",
"DEVTOOLS.VERSION_MANAGER.RELEASE_TYPE": "Тип Релізу",
"DEVTOOLS.VERSION_MANAGER.SYSTEM_INFORMATION": "Системна інформація",
"DEVTOOLS.VERSION_MANAGER.INSTALLATION_PATH": "Шлях встановлення",
"DEVTOOLS.VERSION_MANAGER.ENVIRONMENT": "Environment",
"DEVTOOLS.VERSION_MANAGER.LAST_CHECK": "Остання перевірка",
"DEVTOOLS.VERSION_MANAGER.UPDATE_DEVTOOLS": "Оновлення DevTools",
"DEVTOOLS.VERSION_MANAGER.UPDATE_TO_VERSION": "Оновити до {version}",
"DEVTOOLS.VERSION_MANAGER.UPDATE_DEVTOOLS_MESSAGE": "Натисніть кнопку нижче, щоб встановити останню версію devtools",
"DEVTOOLS.VERSION_MANAGER.MODULE_SUCCESSFULLY_UPDATED": "Модуль успішно оновлено",
"DEVTOOLS.MODULES.TITLE": "Модулі",
"DEVTOOLS.MODULES.FORM_PREVIEW": "Попередній перегляд форми",
"DEVTOOLS.MODULES.FORM_PREVIEW_DESCRIPTION": "Ось як ваша форма виглядатиме для користувачів",
"DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE": "Генератор модулів",
"DEVTOOLS.MODULES.MODULE_NAME": "Назва модуля",
"DEVTOOLS.MODULES.ENTER_MODULE_NAME": "Введіть назву модуля",
"DEVTOOLS.MODULES.TABLE_NAME": "Назва таблиці",
"DEVTOOLS.MODULES.SELECT_TABLE": "Виберіть таблицю",
"DEVTOOLS.MODULES.INCLUDE_IN_MODULE": "Включити до модуля",
"DEVTOOLS.MODULES.INCLUDE_IN_FORM": "Включити до форми",
"DEVTOOLS.MODULES.SHOW_IN_DATATABLE": "Показати в таблиці даних",
"DEVTOOLS.MODULES.COLUMN_TYPE": "Тип стовпця",
"DEVTOOLS.MODULES.COLUMN": "Стовпець",
"DEVTOOLS.MODULES.DIALOG": "Модальне вікно",
"DEVTOOLS.MODULES.DIALOG_SIZE": "Розмір діалогового вікна: {children}",
"DEVTOOLS.MODULES.SHEET": "Бічна панель",
"DEVTOOLS.MODULES.SHEET_SIDE.TOP": "Зверху",
"DEVTOOLS.MODULES.SHEET_SIDE.RIGHT": "Праворуч",
"DEVTOOLS.MODULES.SHEET_SIDE.BOTTOM": "Знизу",
"DEVTOOLS.MODULES.SHEET_SIDE.LEFT": "Ліворуч",
"DEVTOOLS.MODULES.ADD_COLUMN": "Додати стовпець",
"DEVTOOLS.MODULES.REMOVE_COLUMN": "Видалити стовпець",
"DEVTOOLS.MODULES.SELECT_COLUMN": "Виберіть стовпець",
"DEVTOOLS.MODULES.CONFIGURE_FORM_FIELD": "Налаштувати поле форми",
"DEVTOOLS.MODULES.EDIT_LABEL": "Редагувати лейбл",
"DEVTOOLS.MODULES.MODULE_METADATA": "Метадані модуля",
"DEVTOOLS.MODULES.MODULE_FORM": "Форма Модуля",
"DEVTOOLS.MODULES.FORM": "Форма",
"DEVTOOLS.MODULES.FIELD_TYPE": "Тип поля",
"DEVTOOLS.MODULES.SELECT_FIELD_TYPE": "Виберіть тип поля",
"DEVTOOLS.MODULES.DATATABLE": "Таблиця даних",
"DEVTOOLS.MODULES.MANUAL.OPEN_MANUAL": "Відкрити посібник",
"DEVTOOLS.MODULES.MANUAL.DEVTOOLS_MANUAL": "Посібник з інструментів розробки",
"DEVTOOLS.MODULES.MANUAL.MANUAL_FOR_MODULE_CREATION": "Інструкція зі створення модулів",
"DEVTOOLS.MODULES.MANUAL.MANUAL_FOR_MODULE_NAME_DESC": "Назва модуля повинна складатися лише з іменників в однині або іменників у {children}",
"DEVTOOLS.MODULES.MANUAL.RIGHT": "Правильно",
"DEVTOOLS.MODULES.MANUAL.WRONG": "Неправильно",
"DEVTOOLS.MODULES.MANUAL.BADGE": "Бейдж",
"DEVTOOLS.MODULES.MANUAL.ADD_BADGE": "Додати Бейдж",
"DEVTOOLS.MODULES.MANUAL.ADD_SORTING": "Додати сортування",
"DEVTOOLS.MODULES.MANUAL.ADD_SORTING_DESC": "Це додасть сортування до поточного стовпця",
"DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON": "Додати кнопку «Копіювати в буфер обміну»",
"DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON_DESC": "Це додасть кнопку буфера обміну до поточного стовпця поруч із вмістом у клітинці",
"DEVTOOLS.MODULES.MANUAL.ADD_BADGE_DESC": "Це обгорне дані стовпця в компонент Badge",
"DEVTOOLS.MODULES.MANUAL.IMPORTANT": "Важливо",
"DEVTOOLS.MODULES.MANUAL.IMPORTANT_MESSAGE": "Деякі параметри конфігурації можуть перевизначати інші",
"DEVTOOLS.MODULES.MANUAL.BADGE_VARIANT": "Варіант бейджів",
"DEVTOOLS.MODULES.MANUAL.MEANING": "Значення",
"DEVTOOLS.MODULES.MANUAL.BADGE_DESC": "Легенда для варіантів бейджів, що використовуються у модулі генератора",
"DEVTOOLS.MODULES.ERROR.MERGE_FILLED_CELLS": "Ви не можете об'єднати заповнені комірки",
"DEVTOOLS.MODULES.ERROR.ONLY_HORIZONTAL_MERGE_ALLOWED_MESSAGE": "Дозволено лише горизонтальне об'єднання. Виберіть клітинки з одного рядка",
"DEVTOOLS.MODULES.ERROR.ONLY_CONSECUTIVE_MERGE_ALLOWED_MESSAGE": "Будь ласка, виберіть послідовні клітинки в одному рядку",
"DEVTOOLS.MODULES.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так",
"DEVTOOLS.MODULES.ERROR.SELECT_MERGED_CELL_TO_SPLIT": "Будь ласка, виберіть об'єднану клітинку для розділення",
"DEVTOOLS.MODULES.ERROR.SELECT_TWO_CELLS_TO_SWAP": "Виберіть дві комірки для виконання обміну",
"DEVTOOLS.MODULES.ERROR.CANT_SWAP_EMPTY_CELLS": "Не можна поміняти місцями порожні комірки",
"DEVTOOLS.MODULES.LABEL": "Label",
"DEVTOOLS.MODULES.ENTER_FIELD_LABEL": "Введіть label поля",
"DEVTOOLS.MODULES.PLACEHOLDER": "Placeholder",
"DEVTOOLS.MODULES.ENTER_FIELD_PLACEHOLDER": "Введіть текст для placeholder",
"DEVTOOLS.MODULES.ENTER_OPTION_LABEL": "Введіть назву опції (label)",
"DEVTOOLS.MODULES.ENTER_OPTION_VALUE": "Введіть значення опції (value)",
"DEVTOOLS.MODULES.ENTER_OPTION_GROUP": "Група (необов'язково)",
"DEVTOOLS.MODULES.REQUIRED_FIELD": "Обов'язкове поле",
"DEVTOOLS.MODULES.ADD_RED_STAR_MESSAGE": "Додати червону зірку {children} до мітки поля",
"DEVTOOLS.MODULES.CLICK_TO_ADD_FIELD": "Натисніть, щоб додати поле",
"DEVTOOLS.MODULES.UNTITLED_FIELD": "Поле без назви",
"DEVTOOLS.MODULES.MODULE_SUCCESSFULLY_GENERATED": "Модуль успішно згенеровано",
"DEVTOOLS.ACTION.CONFIRM": "Підтвердити",
"DEVTOOLS.ACTION.CANCEL": "Відмінити",
"DEVTOOLS.ACTION.DELETE": "Видалити",
"DEVTOOLS.ACTION.REMOVE": "Видалити",
"DEVTOOLS.ACTION.EDIT": "Редагувати",
"DEVTOOLS.ACTION.SEARCH": "Пошук",
"DEVTOOLS.ACTION.SWAP": "Обміняти",
"DEVTOOLS.ACTION.MERGE": "Об'єднати",
"DEVTOOLS.ACTION.SPLIT": "Розділити",
"DEVTOOLS.ACTION.RESET": "Скинути",
"DEVTOOLS.ACTION.SUBMIT": "Надіслати",
"DEVTOOLS.ACTION.SAVE": "Зберегти",
"DEVTOOLS.ACTION.GENERATE": "Згенерувати",
"DEVTOOLS.ACTION.PREVIEW": "Попередній перегляд",
"DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?",
"DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.",
"DEVTOOLS.STATE.GENERATING": "Генерування",
"DEVTOOLS.STATE.UPDATING": "Оновлюється"
}
@@ -0,0 +1,30 @@
import { sleep } from '@crux-kit/utils';
import { axiosClient } from '#infrastructure/axios';
import type { AutoupdateResponseDTO, GenerateModulePayload, GenerateModuleResponseDTO, GetTableColumnsResponseDTO, GetTablesResponseDTO } from './DevtoolsDTO';
class DevtoolsAPI {
getTables = async () => {
return axiosClient.get<GetTablesResponseDTO>('/makeModule/tables').then((data) => data.data);
};
getTableColumns = async (tableName: string) => {
return axiosClient
.get<GetTableColumnsResponseDTO>(`/makeModule/columns/${tableName}`)
.then((data) => data.data.columns);
};
generateModule = async (payload: GenerateModulePayload) => {
return axiosClient
.post<GenerateModuleResponseDTO>('/makeModule/generate', payload)
.then((data) => data.data);
};
autoupdate = async (): Promise<AutoupdateResponseDTO> => {
// TODO:.
await sleep(4000);
return null;
};
}
export default new DevtoolsAPI();
@@ -0,0 +1,30 @@
import type { ModuleAsset } from '#devtools/entities/ModuleAsset';
import type { ModuleLayoutConfig, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
export interface GenerateModulePayload {
name: string;
table: string;
generate: ModuleAsset[];
form: ModuleLayoutConfig | null;
datatable: ModuleTableConfig | null;
}
export interface GenerateModuleResponseDTO {
message: string;
}
export interface TableColumn {
isDateTime: boolean;
isPassword: boolean;
isPrimary: boolean;
name: string;
type: string;
}
export interface GetTableColumnsResponseDTO {
columns: TableColumn[];
}
export type GetTablesResponseDTO = string[];
export type AutoupdateResponseDTO = null;
@@ -0,0 +1,8 @@
import { useMutation } from '@tanstack/react-query';
import Api from '../DevtoolsAPI';
export const useAutoupdate = () => {
return useMutation({
mutationFn: Api.autoupdate
});
};
@@ -0,0 +1,8 @@
import { useMutation } from '@tanstack/react-query';
import Api from '../DevtoolsAPI';
export const useGenerateModule = () => {
return useMutation({
mutationFn: Api.generateModule
});
};
@@ -0,0 +1,15 @@
import { createKeyBuilder } from '#infrastructure/reactQuery';
const ROOT_KEY = 'DEVTOOLS';
export type TableColumnsQueryPayload = {
tableName: string | null;
};
export const tablesKeyBuilder = createKeyBuilder(ROOT_KEY, 'MODULES', 'TABLES', 'LIST');
export const tableColumnsKeyBuilder = createKeyBuilder<TableColumnsQueryPayload>(
ROOT_KEY,
'MODULES',
'TABLES',
'COLUMNS'
);
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import Api from '../DevtoolsAPI';
import { tableColumnsKeyBuilder, type TableColumnsQueryPayload } from './keys';
const fetcher = async (payload: TableColumnsQueryPayload) => {
if (!payload.tableName) return [];
return Api.getTableColumns(payload.tableName);
};
export const useTableColumns = (tableName: string | null) => {
return useQuery({
queryKey: tableColumnsKeyBuilder.store({ tableName }),
queryFn: () => fetcher({ tableName }),
enabled: !!tableName
});
};
+10
View File
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import Api from '../DevtoolsAPI';
import { tablesKeyBuilder } from './keys';
export const useGetTables = () => {
return useQuery({
queryKey: tablesKeyBuilder.base(),
queryFn: Api.getTables
});
};
@@ -0,0 +1,12 @@
import { LocalStorageService } from '@crux-kit/local-storage';
const storage = new LocalStorageService({ prefix: 'devtools' });
const LAST_UPDATED_AT_KEY = 'lastUpdatedAt';
export function getLastUpdatedAt(): string {
return storage.get<string>(LAST_UPDATED_AT_KEY) ?? '';
}
export function setLastUpdatedAt(value: string): void {
storage.set(LAST_UPDATED_AT_KEY, value);
}
@@ -0,0 +1,11 @@
// prettier-ignore
export enum ModuleAsset {
API = 'api',
MODEL = 'model', // BACKEND
POLICY = 'policy', // BACKEND
ROUTES = 'routes', // BACKEND
CONTROLLER = 'controller', // BACKEND
VALIDATOR = 'validator', // BACKEND
FORM = 'form', // FRONTEND
DATATABLE = 'datatable', // FRONTEND
}
@@ -0,0 +1,27 @@
/* prettier-ignore */
export enum ModuleFieldType {
TEXT = 'text', // InputText
EMAIL = 'email', // InputText
PHONE = 'phone', // InputText
NUMBER = 'number', // InputText
PASSWORD = 'password', // InputText
TEXTAREA = 'textarea', // InputTextarea
SELECT = 'select', // InputSelect
FILE = 'file', // InputFile
DROPZONE = 'dropzone', // InputDropzone
CHECKBOX = 'checkbox', // InputCheckboxGroup
RADIO = 'radio', // InputRadioGroup
SWITCH = 'switch', // InputSwitch
DATE_PICKER_RANGE = 'datepickerRange', // InputDatePickerRange
DATE_PICKER_SINGLE = 'datepickerSingle', // InputDatePicker
DATE_INPUT = 'dateInput', // InputDate
TIME_INPUT_ISO = 'timeInputIso', // InputTime
TIME_INPUT_HMS = 'timeInputHMS', // InputTime
CALENDAR = 'calendar', // InputCalendar
EDITOR = 'editor', // InputTiptapEditor | InputMCEEditor
COLOR_PICKER = 'colorPicker', // InputColorPicker
NUMBER_COUNTER = 'numberCounter', // InputNumberCounter
COMBOBOX = 'comboboxSelect', // InputCombobox
CREATABLE_COMBOBOX = 'creatableCombobox', // InputCreatableCombobox
MULTISELECT = 'multiselect', // InputMultiSelect
}
@@ -0,0 +1,72 @@
import { type ModuleAsset } from './ModuleAsset';
import { type ModuleFieldType } from './ModuleFieldType';
export type ModuleFormContainer =
| 'dialog-default'
| 'dialog-md'
| 'dialog-fullscreen'
| 'sheet-top'
| 'sheet-right'
| 'sheet-bottom'
| 'sheet-left';
export interface ModuleLayoutConfig {
grid: ModuleGridCell[];
rows: number;
cols: number;
selectedCells: string[];
size: ModuleFormContainer;
}
export interface ModuleTableColumn {
label: string;
value: string;
hidden: boolean;
isActionColumn: boolean;
innerProps: {
badge: boolean;
sort: boolean;
copy: boolean;
};
}
export interface ModuleTableConfig {
columns: ModuleTableColumn[];
}
export interface ModuleFieldConfig {
dbColumn: string;
type: ModuleFieldType;
label: string;
placeholder: string;
required: boolean;
options: ModuleFieldOption[];
}
export interface ModuleFieldOption {
value: string;
label: string;
group?: string;
}
export interface ModuleGridCell {
id: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
body: ModuleFieldConfig | null;
isEmpty: boolean;
mergeTo: string | null; // id of main cell
}
export interface ModuleGeneratorMetadata {
name: string;
table: string;
generate: ModuleAsset[];
}
export type ModuleGeneratorConfig = ModuleGeneratorMetadata & {
form: ModuleLayoutConfig;
datatable: ModuleTableConfig;
};
@@ -0,0 +1,64 @@
import { useSearchParams } from 'react-router-dom';
import { usePageMeta } from '@crux-kit/hooks';
import DatatableSection from '#devtools/view/components/ModuleGeneratorDocs/DatatableSection';
import FormSection from '#devtools/view/components/ModuleGeneratorDocs/FormSection';
import MetadataSection from '#devtools/view/components/ModuleGeneratorDocs/MetadataSection';
import OverviewSection from '#devtools/view/components/ModuleGeneratorDocs/OverviewSection';
import TutorialSection from '#devtools/view/components/ModuleGeneratorDocs/TutorialSection';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#shared/ui/Tabs';
import { Container } from '#shared/widgets/common/Container';
const sections = ['overview', 'metadata', 'form', 'datatable', 'tutorial'] as const;
type Section = (typeof sections)[number];
const isSection = (value: string | null): value is Section => {
return sections.includes(value as Section);
};
export default function ModuleGeneratorDocsPage() {
usePageMeta({
title: 'Module generator docs',
description: 'Module generator documentation'
});
const [params, setParams] = useSearchParams();
const requestedSection = params.get('section');
const section = isSection(requestedSection) ? requestedSection : 'overview';
const handleChangeSection = (value: string) => {
if (!isSection(value)) return;
const nextParams = new URLSearchParams(params);
nextParams.set('section', value);
setParams(nextParams, { replace: true });
};
return (
<Container>
<Tabs value={section} onValueChange={handleChangeSection}>
<TabsList variant="default">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="form">Form</TabsTrigger>
<TabsTrigger value="datatable">Datatable</TabsTrigger>
<TabsTrigger value="tutorial">Tutorial</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewSection />
</TabsContent>
<TabsContent value="metadata">
<MetadataSection />
</TabsContent>
<TabsContent value="form">
<FormSection />
</TabsContent>
<TabsContent value="datatable">
<DatatableSection />
</TabsContent>
<TabsContent value="tutorial">
<TutorialSection />
</TabsContent>
</Tabs>
</Container>
);
}
@@ -0,0 +1,70 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { usePageMeta } from '@crux-kit/hooks';
import { uid } from '@crux-kit/utils';
import { getErrorMessage } from '#infrastructure/reactQuery';
import { useGenerateModule } from '#devtools/data/queries/generateModule';
import { ModuleGeneratorConfig } from '#devtools/entities/ModuleGeneratorTypes';
import { fireModuleGenerateAlert } from '#devtools/view/components/ModuleGenerateAlert';
import { ModuleGeneratorForm } from '#devtools/view/forms/ModuleGeneratorForm';
import buildGenerateModulePayload from './lib/buildGenerateModulePayload';
const toastId = uid();
export default function ModuleGeneratorPage() {
const intl = useIntl();
usePageMeta({
title: 'Module generator',
description: 'Generate module with GUI'
});
const { mutate: generateModule, isPending } = useGenerateModule();
const handleSubmit = React.useCallback(
(values: ModuleGeneratorConfig, resetFunc: () => void) => {
fireModuleGenerateAlert({
id: toastId,
status: 'loading',
title: intl.formatMessage({ id: 'STATE.GENERATING' }),
message: intl.formatMessage({
id: 'MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS'
})
});
generateModule(buildGenerateModulePayload(values), {
onSuccess: (response) => {
resetFunc();
fireModuleGenerateAlert({
id: toastId,
status: 'success',
title: intl.formatMessage({ id: 'SUCCESS' }),
message:
response?.message ||
intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_SUCCESSFULLY_GENERATED'
})
});
setTimeout(() => toast.dismiss(toastId), 3000);
},
onError: (error) => {
fireModuleGenerateAlert({
id: toastId,
status: 'error',
title: intl.formatMessage({ id: 'ERROR' }),
message:
getErrorMessage(error) ||
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SOMETHING_WENT_WRONG'
})
});
setTimeout(() => toast.dismiss(toastId), 3000);
}
});
},
[intl, generateModule]
);
return <ModuleGeneratorForm isPending={isPending} onSubmit={handleSubmit} />;
}
@@ -0,0 +1,18 @@
import type { GenerateModulePayload } from '#devtools/data/DevtoolsDTO';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import type { ModuleGeneratorConfig } from '#devtools/entities/ModuleGeneratorTypes';
export default function buildGenerateModulePayload(
values: ModuleGeneratorConfig
): GenerateModulePayload {
const shouldGenerateForm = values.generate.includes(ModuleAsset.FORM);
const shouldGenerateDataTable = values.generate.includes(ModuleAsset.DATATABLE);
return {
name: values.name,
table: values.table,
generate: values.generate,
form: shouldGenerateForm ? values.form : null,
datatable: shouldGenerateDataTable ? values.datatable : null
};
}
@@ -0,0 +1,122 @@
import { useForm } from 'react-hook-form';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { FormElement, FormGrid, GridElement, InputCheckboxGroup, InputColorPicker, InputCombobox, InputMultiSelect, InputRadioGroup, InputSelect, InputSwitch } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import template from '../lib/templates/choiceStateFields';
import FormActions from './FormActions';
export default function ChoiceStateFieldsSection() {
const form = useForm({
defaultValues: {
role: '',
country: '',
skills: ['react'],
color: '#6366f1',
plan: 'team',
channels: ['email'],
notifications: true
}
});
return (
<SandboxShowcase
title="Choice and state fields"
description="Select, search, multi-select, color, radio, checkbox, and switch controls with independent form state."
code={template}
className="flex-nowrap"
>
<FormProvider {...form}>
<FormElement className="w-full" onSubmit={form.handleSubmit(() => undefined)}>
<FormGrid columns={12}>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputSelect
name="role"
label="Select"
placeholder="Select a role"
description="Standard select for a short list of options."
options={[
{ label: 'Administrator', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'Viewer', value: 'viewer' }
]}
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputCombobox
name="country"
label="Combobox"
placeholder="Select a country"
inputPlaceholder="Search countries…"
description="Searchable grouped option list."
groups={[
{
label: 'Europe',
options: [
{ label: 'Ukraine', value: 'ua' },
{ label: 'Poland', value: 'pl' }
]
},
{ label: 'Americas', options: [{ label: 'United States', value: 'us' }] }
]}
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputMultiSelect
name="skills"
label="Multi-select"
placeholder="Select skills"
description="Select several values with compact badges."
options={[
{ label: 'React', value: 'react' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'Node.js', value: 'node' }
]}
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputColorPicker
name="color"
label="Color picker"
description="Choose or enter a color value."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputRadioGroup
name="plan"
label="Radio group"
description="Choose exactly one mutually exclusive option."
options={[
{ label: 'Personal', value: 'personal' },
{ label: 'Team', value: 'team' },
{ label: 'Enterprise', value: 'enterprise' }
]}
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputCheckboxGroup
name="channels"
label="Checkbox group"
description="Choose any number of independent options."
options={[
{ label: 'Email', value: 'email', description: 'Workflow updates' },
{ label: 'Push', value: 'push', description: 'Browser alerts' },
{ label: 'SMS', value: 'sms', description: 'Critical alerts' }
]}
/>
</GridElement>
<GridElement colSpan={12}>
<InputSwitch
name="notifications"
label="Email notifications"
description="Switches represent settings that take effect immediately."
/>
</GridElement>
<GridElement colSpan={12}>
<FormActions form={form} />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
</SandboxShowcase>
);
}
@@ -0,0 +1,74 @@
import { useForm } from 'react-hook-form';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { FormElement, FormGrid, GridElement, InputCalendar, InputDate, InputDatePicker, InputDatePickerRange, InputTime } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import template from '../lib/templates/dateTimeFields';
import FormActions from './FormActions';
export default function DateTimeFieldsSection() {
const form = useForm({
defaultValues: {
date: '',
time: '',
appointment: undefined,
period: undefined,
calendarDate: undefined
}
});
return (
<SandboxShowcase
title="Date and time fields"
description="Segmented date and time inputs alongside popover, range, and inline calendar controls."
code={template}
className="flex-nowrap"
>
<FormProvider {...form}>
<FormElement className="w-full" onSubmit={form.handleSubmit(() => undefined)}>
<FormGrid columns={12}>
<GridElement className="col-span-12 md:col-span-4" colSpan={4}>
<InputDate
name="date"
label="Segmented date"
description="Keyboard-friendly date entry."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-4" colSpan={4}>
<InputTime
name="time"
label="Time"
format="HMS"
description="Localized time entry."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-4" colSpan={4}>
<InputDatePicker
name="appointment"
label="Date picker"
description="A single date in a popover calendar."
/>
</GridElement>
<GridElement className="col-span-12 lg:col-span-6" colSpan={6}>
<InputDatePickerRange
name="period"
label="Date range"
numberOfMonths={2}
description="Select a start and end date."
/>
</GridElement>
<GridElement className="col-span-12 lg:col-span-6" colSpan={6}>
<InputCalendar
name="calendarDate"
label="Inline calendar"
description="Calendar displayed directly in the form."
/>
</GridElement>
<GridElement colSpan={12}>
<FormActions form={form} />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
</SandboxShowcase>
);
}
@@ -0,0 +1,61 @@
import { useForm } from 'react-hook-form';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { FormElement, FormGrid, GridElement, InputDropzone, InputFile, InputFilePicker } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import template from '../lib/templates/fileFields';
import FormActions from './FormActions';
export default function FileFieldsSection() {
const form = useForm({
defaultValues: { attachment: null, documents: [], images: [], pickedFiles: null }
});
return (
<SandboxShowcase
title="File fields"
description="Native single and multiple file inputs, image dropzone, and integrated file-manager picker."
code={template}
className="flex-nowrap"
>
<FormProvider {...form}>
<FormElement className="w-full" onSubmit={form.handleSubmit(() => undefined)}>
<FormGrid columns={12}>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputFile
name="attachment"
label="Single file"
description="Native file selection with reset support."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputFile
name="documents"
label="Multiple files"
description="Native selection configured for multiple files."
multiple
/>
</GridElement>
<GridElement className="col-span-12 lg:col-span-6" colSpan={6}>
<InputDropzone
name="images"
label="Image dropzone"
description="Drag, drop, preview, and remove image files."
/>
</GridElement>
<GridElement className="col-span-12 lg:col-span-6" colSpan={6}>
<InputFilePicker
name="pickedFiles"
label="File manager picker"
placeholder="Select a local or managed file"
description="Choose from the file manager or local device."
/>
</GridElement>
<GridElement colSpan={12}>
<FormActions form={form} />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
</SandboxShowcase>
);
}
@@ -0,0 +1,13 @@
import { FieldValues, UseFormReturn } from 'react-hook-form';
import { Button } from '#shared/ui/Button';
export default function FormActions<T extends FieldValues>({ form }: { form: UseFormReturn<T> }) {
return (
<div className="border-border flex flex-wrap justify-end gap-2 border-t pt-5">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit">Submit example</Button>
</div>
);
}
@@ -0,0 +1,45 @@
import { useForm } from 'react-hook-form';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { FormElement, FormGrid, GridElement, InputMCEEditor, InputTiptapEditor } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import template from '../lib/templates/richTextField';
import FormActions from './FormActions';
export default function RichTextFieldSection() {
const form = useForm({ defaultValues: { content: '', contentLight: '' } });
return (
<SandboxShowcase
title="Rich text field"
description="Full rich-text editing through the composed TinyMCE field and shared toolbar configuration."
code={template}
className="flex-nowrap"
>
<FormProvider {...form}>
<FormElement className="w-full" onSubmit={form.handleSubmit(() => undefined)}>
<FormGrid columns={12}>
<GridElement colSpan={12}>
<InputMCEEditor
name="content"
label="Rich text editor (TinyMCE)"
placeholder="Write formatted content…"
description="Full rich-text editing with the shared TinyMCE configuration."
/>
</GridElement>
<GridElement colSpan={12}>
<InputTiptapEditor
name="contentLight"
label="Rich text editor (TipTap · light preset)"
placeholder="Write formatted content…"
description="Free, headless editor from @crux-kit/editor-tiptap. HTML in / HTML out."
/>
</GridElement>
<GridElement colSpan={12}>
<FormActions form={form} />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
</SandboxShowcase>
);
}
@@ -0,0 +1,100 @@
import { useForm } from 'react-hook-form';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { FormElement, FormGrid, GridElement, InputNumberCounter, InputPhone, InputText, InputTextarea } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import template from '../lib/templates/textNumericFields';
import FormActions from './FormActions';
export default function TextNumericFieldsSection() {
const form = useForm({
defaultValues: {
name: '',
email: '',
password: '',
amount: 100,
phone: '',
quantity: 1,
description: ''
}
});
return (
<SandboxShowcase
title="Text and numeric fields"
description="Text, email, password, numeric, phone, counter, and textarea composed fields with labels and descriptions."
code={template}
className="flex-nowrap"
>
<FormProvider {...form}>
<FormElement className="w-full" onSubmit={form.handleSubmit(() => undefined)}>
<FormGrid columns={12}>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputText
name="name"
label="Name"
placeholder="Enter a name"
description="Standard text input with a required indicator."
required
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputText
name="email"
type="email"
label="Email"
placeholder="developer@example.com"
description="Email type with its contextual icon."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputText
name="password"
type="password"
label="Password"
autoComplete="new-password"
placeholder="Enter a secure password"
description="Password type with a visibility toggle."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputText
name="amount"
type="number"
label="Amount"
placeholder="0"
description="Native numeric input."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputPhone
name="phone"
label="Phone"
description="Country-aware phone mask and selector."
/>
</GridElement>
<GridElement className="col-span-12 md:col-span-6" colSpan={6}>
<InputNumberCounter
name="quantity"
label="Quantity"
min={0}
max={10}
description="Bounded stepper with a minimum and maximum."
/>
</GridElement>
<GridElement colSpan={12}>
<InputTextarea
name="description"
label="Description"
placeholder="Add a concise description…"
description="Use for longer plain-text content."
/>
</GridElement>
<GridElement colSpan={12}>
<FormActions form={form} />
</GridElement>
</FormGrid>
</FormElement>
</FormProvider>
</SandboxShowcase>
);
}
@@ -0,0 +1,17 @@
import ChoiceStateFieldsSection from './components/ChoiceStateFieldsSection';
import DateTimeFieldsSection from './components/DateTimeFieldsSection';
import FileFieldsSection from './components/FileFieldsSection';
import RichTextFieldSection from './components/RichTextFieldSection';
import TextNumericFieldsSection from './components/TextNumericFieldsSection';
export default function SandboxFormsPage() {
return (
<div className="grid gap-5">
<TextNumericFieldsSection />
<ChoiceStateFieldsSection />
<DateTimeFieldsSection />
<FileFieldsSection />
<RichTextFieldSection />
</div>
);
}
@@ -0,0 +1,17 @@
export default `import { useForm } from 'react-hook-form';
import { InputCheckboxGroup, InputColorPicker, InputCombobox, InputMultiSelect, InputRadioGroup, InputSelect, InputSwitch } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
const form = useForm({
defaultValues: { role: '', country: '', skills: ['react'], color: '#6366f1', plan: 'team', channels: ['email'], notifications: true },
});
<FormProvider {...form}>
<InputSelect name="role" label="Role" options={[{ label: 'Admin', value: 'admin' }]} />
<InputCombobox name="country" label="Country" groups={[{ options: [{ label: 'Ukraine', value: 'ua' }] }]} />
<InputMultiSelect name="skills" label="Skills" options={[{ label: 'React', value: 'react' }]} />
<InputColorPicker name="color" label="Color" />
<InputRadioGroup name="plan" label="Plan" options={[{ label: 'Team', value: 'team' }]} />
<InputCheckboxGroup name="channels" label="Channels" options={[{ label: 'Email', value: 'email' }]} />
<InputSwitch name="notifications" label="Email notifications" />
</FormProvider>`;
@@ -0,0 +1,15 @@
export default `import { useForm } from 'react-hook-form';
import { InputCalendar, InputDate, InputDatePicker, InputDatePickerRange, InputTime } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
const form = useForm({
defaultValues: { date: '', time: '', appointment: undefined, period: undefined, calendarDate: undefined },
});
<FormProvider {...form}>
<InputDate name="date" label="Date" />
<InputTime name="time" label="Time" format="HMS" />
<InputDatePicker name="appointment" label="Appointment" />
<InputDatePickerRange name="period" label="Date range" numberOfMonths={2} />
<InputCalendar name="calendarDate" label="Inline calendar" />
</FormProvider>`;
@@ -0,0 +1,14 @@
export default `import { useForm } from 'react-hook-form';
import { InputDropzone, InputFile, InputFilePicker } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
const form = useForm({
defaultValues: { attachment: null, documents: [], images: [], pickedFiles: null },
});
<FormProvider {...form}>
<InputFile name="attachment" label="Single file" />
<InputFile name="documents" label="Multiple files" multiple />
<InputDropzone name="images" label="Image dropzone" />
<InputFilePicker name="pickedFiles" label="File manager picker" />
</FormProvider>`;
@@ -0,0 +1,13 @@
export default `import { useForm } from 'react-hook-form';
import { InputMCEEditor } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
const form = useForm({ defaultValues: { content: '' } });
<FormProvider {...form}>
<InputMCEEditor
name="content"
label="Rich text editor"
placeholder="Write formatted content..."
/>
</FormProvider>`;
@@ -0,0 +1,21 @@
export default `import { useForm } from 'react-hook-form';
import { FormElement, FormGrid, GridElement, InputNumberCounter, InputPhone, InputText, InputTextarea } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
const form = useForm({
defaultValues: { name: '', email: '', password: '', amount: 100, phone: '', quantity: 1, description: '' },
});
<FormProvider {...form}>
<FormElement onSubmit={form.handleSubmit(console.log)}>
<FormGrid columns={2}>
<GridElement colSpan={1}><InputText name="name" label="Name" required /></GridElement>
<GridElement colSpan={1}><InputText name="email" type="email" label="Email" /></GridElement>
<GridElement colSpan={1}><InputText name="password" type="password" label="Password" /></GridElement>
<GridElement colSpan={1}><InputText name="amount" type="number" label="Amount" /></GridElement>
<GridElement colSpan={1}><InputPhone name="phone" label="Phone" /></GridElement>
<GridElement colSpan={1}><InputNumberCounter name="quantity" label="Quantity" min={0} max={10} /></GridElement>
<GridElement colSpan={2}><InputTextarea name="description" label="Description" /></GridElement>
</FormGrid>
</FormElement>
</FormProvider>`;
@@ -0,0 +1,39 @@
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '#shared/ui/Accordion';
import accordion from '../lib/templates/accordion';
import titleCase from '../lib/titleCase';
export default function AccordionSandbox() {
return (
<SandboxShowcase
title="Accordion"
description="Accordions organize related sections of content. Choose the visual variant and arrow or plus indicator to match the surrounding hierarchy."
code={accordion}
className="flex-nowrap"
>
<div className="grid w-full gap-5 lg:grid-cols-3">
{(['default', 'outline', 'solid'] as const).map((variant, index) => (
<div key={variant} className="space-y-2">
<h3 className="text-sm font-semibold">{titleCase(variant)}</h3>
<Accordion
type="single"
collapsible
defaultValue={index === 0 ? 'item-1' : undefined}
variant={variant}
indicator={variant === 'outline' ? 'plus' : 'arrow'}
>
<AccordionItem value="item-1">
<AccordionTrigger>What is Sandbox?</AccordionTrigger>
<AccordionContent>A visual reference for shared components.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Can it be interactive?</AccordionTrigger>
<AccordionContent>Yes. Examples preserve their real behavior.</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,72 @@
import { CircleAlert, CircleCheck, Info } from 'lucide-react';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import alert from '../lib/templates/alert';
import titleCase from '../lib/titleCase';
import ExampleGroup from './ExampleGroup';
const appearances = ['solid', 'outline', 'light', 'stroke'] as const;
const variants = [
'secondary',
'primary',
'destructive',
'success',
'info',
'mono',
'warning'
] as const;
export default function AlertSandbox() {
return (
<SandboxShowcase
title="Alerts"
description="Alerts communicate contextual status. Combine semantic variants with solid, outline, light, or stroke appearances and choose size based on message density."
code={alert}
className="flex-nowrap"
>
<div className="grid w-full gap-6">
{appearances.map((appearance) => (
<ExampleGroup key={appearance} title={`${titleCase(appearance)} appearance`}>
<div className="grid w-full gap-3 lg:grid-cols-2">
{variants.map((variant) => (
<Alert key={variant} variant={variant} appearance={appearance}>
<AlertIcon>
{variant === 'success' ? (
<CircleCheck />
) : variant === 'info' ? (
<Info />
) : (
<CircleAlert />
)}
</AlertIcon>
<AlertContent>
<AlertTitle>{titleCase(variant)} alert</AlertTitle>
<AlertDescription>Contextual information for this state.</AlertDescription>
</AlertContent>
</Alert>
))}
</div>
</ExampleGroup>
))}
<ExampleGroup title="Sizes and dismiss action">
<div className="grid w-full gap-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Alert key={size} variant="primary" appearance="light" size={size} close>
<AlertIcon>
<Info />
</AlertIcon>
<AlertContent>
<AlertTitle>Size {size}</AlertTitle>
<AlertDescription>
Alert sizing keeps content and icon proportions aligned.
</AlertDescription>
</AlertContent>
</Alert>
))}
</div>
</ExampleGroup>
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,70 @@
import { Check, X } from 'lucide-react';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Badge, BadgeButton, BadgeDot } from '#shared/ui/Badge';
import badge from '../lib/templates/badge';
import titleCase from '../lib/titleCase';
import ExampleGroup from './ExampleGroup';
const variants = [
'primary',
'secondary',
'success',
'warning',
'info',
'mono',
'destructive'
] as const;
const appearances = ['solid', 'outline', 'light', 'stroke', 'ghost'] as const;
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
export default function BadgeSandbox() {
return (
<SandboxShowcase
title="Badges"
description="Badges display compact labels, statuses, and removable metadata across semantic variants, appearances, sizes, and shapes."
code={badge}
className="flex-nowrap"
>
<div className="grid w-full gap-6">
<ExampleGroup title="Variants">
{variants.map((variant) => (
<Badge key={variant} variant={variant}>
{titleCase(variant)}
</Badge>
))}
</ExampleGroup>
{appearances.map((appearance) => (
<ExampleGroup key={appearance} title={`${titleCase(appearance)} appearance`}>
{variants.map((variant) => (
<Badge key={variant} variant={variant} appearance={appearance}>
{titleCase(variant)}
</Badge>
))}
</ExampleGroup>
))}
<ExampleGroup title="Sizes and composition">
{sizes.map((size) => (
<Badge key={size} variant="primary" size={size}>
{size.toUpperCase()}
</Badge>
))}
<Badge variant="success" appearance="light" shape="circle">
<BadgeDot /> Active
</Badge>
<Badge variant="primary">
Verified <Check />
</Badge>
<Badge variant="destructive" appearance="light">
Removable
<BadgeButton aria-label="Remove badge">
<X />
</BadgeButton>
</Badge>
<Badge disabled>Disabled</Badge>
</ExampleGroup>
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,76 @@
import { Bell, ChevronDown, Plus, Trash2 } from 'lucide-react';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Button, ButtonArrow } from '#shared/ui/Button';
import button from '../lib/templates/button';
import titleCase from '../lib/titleCase';
import ExampleGroup from './ExampleGroup';
const variants = [
'primary',
'mono',
'destructive',
'secondary',
'outline',
'dashed',
'ghost',
'dim'
] as const;
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
export default function ButtonSandbox() {
return (
<SandboxShowcase
title="Buttons"
className="flex-nowrap"
description="Button variants communicate emphasis and intent. Size controls density, appearance changes visual weight, and mode adapts the component for icons, links, and input-like triggers."
code={button}
>
<div className="grid w-full min-w-0 gap-6">
<ExampleGroup title="Variants">
{variants.map((variant) => (
<Button key={variant} variant={variant}>
{variant === 'primary' && <Plus />}
{variant === 'destructive' && <Trash2 />}
{titleCase(variant)}
</Button>
))}
<Button disabled>Disabled</Button>
</ExampleGroup>
<ExampleGroup title="Sizes">
{sizes.map((size) => (
<Button key={size} variant="outline" size={size}>
Size {size}
</Button>
))}
</ExampleGroup>
<ExampleGroup title="Appearance">
<Button variant="primary">Default primary</Button>
<Button variant="primary" appearance="ghost">
Ghost primary
</Button>
<Button variant="destructive">Default destructive</Button>
<Button variant="destructive" appearance="ghost">
Ghost destructive
</Button>
</ExampleGroup>
<ExampleGroup title="Modes">
<Button variant="outline" mode="icon" aria-label="Notifications">
<Bell />
</Button>
<Button variant="primary" mode="link" underline="solid">
Hover link
</Button>
<Button variant="primary" mode="link" underlined="dashed">
Underlined link
</Button>
<Button variant="outline" mode="input">
Input trigger <ButtonArrow icon={ChevronDown} />
</Button>
</ExampleGroup>
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,43 @@
import { ChevronDown } from 'lucide-react';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Button } from '#shared/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '#shared/ui/Collapsible';
import collapsible from '../lib/templates/collapsible';
export default function CollapsibleSandbox() {
return (
<SandboxShowcase
title="Collapsible"
description="Collapsible reveals optional content from a single trigger without imposing the grouped structure of an accordion."
code={collapsible}
className="flex-nowrap"
>
<div className="grid w-full gap-5 md:grid-cols-2">
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full">
Advanced options <ChevronDown />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-border bg-muted/40 text-muted-foreground rounded-md border p-4 text-sm">
Closed by default. Use this pattern for optional settings.
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible defaultOpen className="space-y-2">
<CollapsibleTrigger asChild>
<Button variant="secondary" className="w-full">
Developer details <ChevronDown />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-border bg-muted/40 text-muted-foreground rounded-md border p-4 text-sm">
Open by default. The content animates when its state changes.
</div>
</CollapsibleContent>
</Collapsible>
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,11 @@
export default function ExampleGroup({
title,
children
}: React.PropsWithChildren<{ title: string }>) {
return (
<div className="w-full min-w-0 space-y-3">
<h3 className="text-foreground text-sm font-semibold">{title}</h3>
<div className="flex flex-wrap items-center gap-3">{children}</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { CircleCheck } from 'lucide-react';
import SandboxShowcase from '#devtools/view/components/SandboxShowcase';
import { Alert, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Badge } from '#shared/ui/Badge';
import { Marquee } from '#shared/ui/Marquee';
import marquee from '../lib/templates/marquee';
import ExampleGroup from './ExampleGroup';
export default function MarqueeSandbox() {
return (
<SandboxShowcase
title="Marquee"
description="Marquee continuously cycles repeated content. It supports direction, hover pause, repetition, speed, and vertical orientation."
code={marquee}
className="flex-nowrap"
>
<div className="w-full min-w-0 space-y-6">
<ExampleGroup title="Horizontal with pause on hover">
<Marquee
pauseOnHover
repeat={4}
className="border-border w-full max-w-full min-w-0 rounded-lg border [--duration:20s]"
>
{['React', 'TypeScript', 'Radix UI', 'Tailwind CSS'].map((item) => (
<Badge key={item} variant="primary" appearance="light" size="lg">
{item}
</Badge>
))}
</Marquee>
</ExampleGroup>
<ExampleGroup title="Reverse direction">
<Marquee
reverse
repeat={3}
className="border-border w-full rounded-lg border [--duration:16s]"
>
{['Fast', 'Accessible', 'Composable', 'Consistent'].map((item) => (
<div key={item} className="bg-muted rounded-md px-4 py-2 text-sm font-medium">
{item}
</div>
))}
</Marquee>
</ExampleGroup>
<ExampleGroup title="Vertical">
<Marquee
vertical
pauseOnHover
repeat={3}
className="border-border h-48 w-full max-w-sm rounded-lg border [--duration:12s]"
>
{['Build completed', 'Tests passed', 'Preview deployed'].map((item) => (
<Alert key={item} variant="success" appearance="light" size="sm">
<AlertIcon>
<CircleCheck />
</AlertIcon>
<AlertTitle>{item}</AlertTitle>
</Alert>
))}
</Marquee>
</ExampleGroup>
</div>
</SandboxShowcase>
);
}
@@ -0,0 +1,19 @@
import AccordionSandbox from './components/AccordionSandbox';
import AlertSandbox from './components/AlertSandbox';
import BadgeSandbox from './components/BadgeSandbox';
import ButtonSandbox from './components/ButtonSandbox';
import CollapsibleSandbox from './components/CollapsibleSandbox';
import MarqueeSandbox from './components/MarqueeSandbox';
export default function SandboxUIPage() {
return (
<div className="grid gap-5">
<ButtonSandbox />
<BadgeSandbox />
<AlertSandbox />
<AccordionSandbox />
<CollapsibleSandbox />
<MarqueeSandbox />
</div>
);
}
@@ -0,0 +1,17 @@
export default `
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '#shared/ui/Accordion';
<Accordion type="single" collapsible variant="outline" indicator="plus">
<AccordionItem value="item-1">
<AccordionTrigger>What is this component?</AccordionTrigger>
<AccordionContent>
Accordions reveal one content section at a time.
</AccordionContent>
</AccordionItem>
</Accordion>
`;
@@ -0,0 +1,18 @@
export default `
import { CircleCheck } from 'lucide-react';
import {
Alert,
AlertContent,
AlertDescription,
AlertIcon,
AlertTitle,
} from '#shared/ui/Alert';
<Alert variant="success" appearance="light" size="md" close>
<AlertIcon><CircleCheck /></AlertIcon>
<AlertContent>
<AlertTitle>Changes saved</AlertTitle>
<AlertDescription>The record is now up to date.</AlertDescription>
</AlertContent>
</Alert>
`;
@@ -0,0 +1,13 @@
export default `
import { Check, X } from 'lucide-react';
import { Badge, BadgeButton, BadgeDot } from '#shared/ui/Badge';
<Badge variant="success" appearance="solid">Active</Badge>
<Badge variant="warning" appearance="outline">Pending</Badge>
<Badge variant="primary" appearance="light"><BadgeDot /> In progress</Badge>
<Badge variant="info" size="lg" shape="circle">New</Badge>
<Badge variant="destructive">
Invalid
<BadgeButton aria-label="Remove"><X /></BadgeButton>
</Badge>
`;
@@ -0,0 +1,11 @@
export default `
import { Bell, ChevronDown, Plus } from 'lucide-react';
import { Button, ButtonArrow } from '#shared/ui/Button';
<Button variant="primary" size="md"><Plus /> Create</Button>
<Button variant="primary" appearance="ghost">Ghost primary</Button>
<Button variant="outline" mode="icon" aria-label="Notifications"><Bell /></Button>
<Button variant="primary" mode="link" underline="solid">Documentation</Button>
<Button variant="outline" mode="input">Choose option<ButtonArrow /></Button>
<Button disabled>Disabled</Button>
`;
@@ -0,0 +1,18 @@
export default `
import { ChevronDown } from 'lucide-react';
import { Button } from '#shared/ui/Button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '#shared/ui/Collapsible';
<Collapsible defaultOpen className="space-y-2">
<CollapsibleTrigger asChild>
<Button variant="outline">Advanced options <ChevronDown /></Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-md border p-4">Hidden content</div>
</CollapsibleContent>
</Collapsible>
`;
@@ -0,0 +1,14 @@
export default `
import { Badge } from '#shared/ui/Badge';
import { Marquee } from '#shared/ui/Marquee';
<Marquee pauseOnHover repeat={4} className="[--duration:20s]">
<Badge variant="primary">React</Badge>
<Badge variant="info">TypeScript</Badge>
</Marquee>
<Marquee reverse vertical className="h-40 [--duration:12s]">
<div>First item</div>
<div>Second item</div>
</Marquee>
`;
@@ -0,0 +1,3 @@
export default function titleCase(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
@@ -0,0 +1,15 @@
import { usePageMeta } from '@crux-kit/hooks';
import { MODE } from '#app/config/app';
import VersionManager from '#devtools/view/widgets/VersionManager';
import useVersionManager from './lib/useVersionManager';
export default function VersionManagerPage() {
usePageMeta({
title: 'Version Manager',
description: 'Module generator version manager'
});
const versionManager = useVersionManager();
return <VersionManager {...versionManager} environment={MODE} />;
}
@@ -0,0 +1,77 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { uid } from '@crux-kit/utils';
import { getErrorMessage } from '#infrastructure/reactQuery';
import { useAutoupdate } from '#devtools/data/queries/autoupdate';
import { getLastUpdatedAt, setLastUpdatedAt } from '#devtools/data/stores/versionManagerStorage';
import { fireModuleGenerateAlert } from '#devtools/view/components/ModuleGenerateAlert';
const toastId = uid();
const CURRENT_VERSION = '2.4.1';
const LATEST_VERSION = '2.5.0';
export default function useVersionManager() {
const intl = useIntl();
const [lastUpdated, setLastUpdated] = React.useState(getLastUpdatedAt);
const { mutate: updateDevtools, isPending } = useAutoupdate();
const markUpdated = React.useCallback(() => {
const date = new Date().toISOString();
setLastUpdatedAt(date);
setLastUpdated(date);
}, []);
const onUpdate = () => {
fireModuleGenerateAlert({
id: toastId,
status: 'loading',
title: intl.formatMessage({ id: 'STATE.UPDATING' }),
message: intl.formatMessage({
id: 'MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS'
})
});
updateDevtools(undefined, {
onSuccess: () => {
markUpdated();
fireModuleGenerateAlert({
id: toastId,
status: 'success',
title: 'No.....',
message: (
<img
src="https://imgs.search.brave.com/IuKsmfqEOecxQphkj-_BjWvUWSlMnlfBnCQSTYMmq2A/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9pMS5z/bmRjZG4uY29tL2Fy/dHdvcmtzLTAwMDE0/NDUxODE3Ni02M3lh/bTQtdDEwODB4MTA4/MC5qcGc"
height={200}
width={200}
/>
)
});
setTimeout(() => toast.dismiss(toastId), 3000);
},
onError: (error) => {
fireModuleGenerateAlert({
id: toastId,
status: 'error',
title: intl.formatMessage({ id: 'ERROR' }),
message:
getErrorMessage(error) ||
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SOMETHING_WENT_WRONG'
})
});
setTimeout(() => toast.dismiss(toastId), 3000);
}
});
};
return {
currentVersion: CURRENT_VERSION,
isPending,
lastUpdated,
latestVersion: LATEST_VERSION,
onUpdate
};
}
@@ -0,0 +1,50 @@
import type { ModuleFieldOption } from '#devtools/entities/ModuleGeneratorTypes';
interface FieldOption {
label: string;
value: string;
}
export interface ComboboxOptionGroup {
label?: string;
options: FieldOption[];
}
export interface MultiSelectOptionGroup {
heading: string;
options: FieldOption[];
}
export function hasGroupedOptions(options: ModuleFieldOption[]): boolean {
return options.some((option) => option.group?.trim());
}
export function toFlatOptions(options: ModuleFieldOption[]): FieldOption[] {
return options.map((option) => ({ label: option.label, value: option.value }));
}
export function toComboboxGroups(options: ModuleFieldOption[]): ComboboxOptionGroup[] {
const groups = new Map<string, ComboboxOptionGroup>();
for (const option of options) {
const key = option.group?.trim() ?? '';
const group = groups.get(key) ?? { label: key || undefined, options: [] };
group.options.push({ label: option.label, value: option.value });
groups.set(key, group);
}
return [...groups.values()];
}
export function toMultiSelectOptions(
options: ModuleFieldOption[]
): FieldOption[] | MultiSelectOptionGroup[] {
if (!hasGroupedOptions(options)) {
return toFlatOptions(options);
}
return toComboboxGroups(options).map((group) => ({
heading: group.label ?? '',
options: group.options
}));
}
@@ -0,0 +1,9 @@
export default function snakeToCamel(snake = ''): string {
const camel = snake.replace(/_([a-z])/g, (_, character) => character.toUpperCase());
if (camel.slice(-1) === 's') {
return camel.slice(0, -1);
}
return camel;
}
@@ -0,0 +1,110 @@
import { Plus, RotateCcw, Split } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@crux-kit/utils';
import type { ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { Badge } from '#shared/ui/Badge';
import { Button } from '#shared/ui/Button';
import { Checkbox } from '#shared/ui/Checkbox';
import { FIELD_ICONS_MAP } from './config';
export const Cell: React.FC<{
cell: ModuleGridCell;
isSelected: boolean;
disabled: boolean;
showSplitButton: boolean;
onClick: () => void;
onSelect: (isSelected: boolean) => void;
onReset: () => void;
onSplit: () => void;
}> = ({ cell, isSelected, disabled, showSplitButton, onClick, onSelect, onReset, onSplit }) => {
const intl = useIntl();
const cellStyle = {
gridColumn: `span ${cell.colSpan}`,
gridRow: `span ${cell.rowSpan}`
};
const fieldType = cell.body?.type;
const dbColumnName = cell.body?.dbColumn;
const Icon = fieldType ? FIELD_ICONS_MAP[fieldType] : Plus;
return (
<div
style={cellStyle}
className={cn(
'overflow-hidden',
'border-input bg-muted hover:bg-primary/10 hover:border-primary/20 relative cursor-pointer rounded-lg border border-dashed p-4 transition-all',
isSelected && 'bg-primary/10 border-primary/20 hover:border-primary/20 border-solid',
disabled && 'pointer-events-none opacity-50'
)}
onClick={onClick}
>
<Checkbox
className="absolute top-2 left-2 z-10"
checked={isSelected}
onCheckedChange={onSelect}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex h-full flex-col items-center justify-center text-center">
{Icon && <Icon className="mb-2 h-6 w-6 text-gray-500" />}
{cell.isEmpty ? (
<span className="text-sm text-gray-500">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.CLICK_TO_ADD_FIELD' })}
</span>
) : (
<div>
<div className="text-sm font-medium text-gray-700">
{cell.body?.label || intl.formatMessage({ id: 'DEVTOOLS.MODULES.UNTITLED_FIELD' })}
</div>
{dbColumnName && (
<Badge variant="warning" appearance="outline" size="sm" className="ml-2">
{dbColumnName}
</Badge>
)}
<Badge variant="success" appearance="outline" size="sm" className="ml-2">
{fieldType}
</Badge>
</div>
)}
</div>
<div className="absolute top-1 right-2 z-10">
{showSplitButton && (
<Button
type="button"
onClick={(e) => {
e.stopPropagation();
onSplit();
}}
size="sm"
mode="icon"
variant="ghost"
className="hover:bg-transparent"
>
<Split className="h-4 w-4" />
</Button>
)}
{cell.isEmpty === false && (
<Button
type="button"
onClick={(e) => {
e.stopPropagation();
onReset();
}}
size="sm"
mode="icon"
variant="ghost"
appearance="ghost"
className="hover:bg-transparent"
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,32 @@
import { Calendar1, CalendarDays, CalendarFold, CalendarRange, CheckCircle2, CheckSquare2, Clock4, Component, Diff, Droplet, EyeOff, Hash, List, ListPlus, LucideProps, LucideSwitchCamera, Mail, Palette, Phone, Text, TextSelect, Type, Upload, WholeWord } from 'lucide-react';
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
export const FIELD_ICONS_MAP: Record<
ModuleFieldType,
React.ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & React.RefAttributes<SVGSVGElement>>
> = /* prettier-ignore */ {
[ModuleFieldType.TEXT] : Type,
[ModuleFieldType.NUMBER] : Hash,
[ModuleFieldType.EMAIL] : Mail,
[ModuleFieldType.PASSWORD] : EyeOff,
[ModuleFieldType.SELECT] : List,
[ModuleFieldType.FILE] : Upload,
[ModuleFieldType.TEXTAREA] : Text,
[ModuleFieldType.DROPZONE] : Droplet,
[ModuleFieldType.RADIO] : CheckCircle2,
[ModuleFieldType.CHECKBOX] : CheckSquare2,
[ModuleFieldType.SWITCH] : LucideSwitchCamera,
[ModuleFieldType.DATE_PICKER_RANGE] : CalendarRange,
[ModuleFieldType.DATE_PICKER_SINGLE] : CalendarFold,
[ModuleFieldType.CALENDAR] : CalendarDays,
[ModuleFieldType.DATE_INPUT] : Calendar1,
[ModuleFieldType.TIME_INPUT_ISO] : Clock4,
[ModuleFieldType.TIME_INPUT_HMS] : Clock4,
[ModuleFieldType.EDITOR] : WholeWord,
[ModuleFieldType.COLOR_PICKER] : Palette,
[ModuleFieldType.NUMBER_COUNTER] : Diff,
[ModuleFieldType.PHONE] : Phone,
[ModuleFieldType.COMBOBOX] : Component,
[ModuleFieldType.CREATABLE_COMBOBOX] : ListPlus,
[ModuleFieldType.MULTISELECT] : TextSelect
};
@@ -0,0 +1,7 @@
import type { ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
export function getMainCellsIds(grid: ModuleGridCell[]) {
return Array.from(
new Set(grid.map((cell) => cell.mergeTo).filter((cellId): cellId is string => cellId != null))
);
}
@@ -0,0 +1,51 @@
import React from 'react';
import type { ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { Cell } from './Cell';
import { getMainCellsIds } from './helpers';
const ModuleFormGrid: React.FC<{
data: ModuleGridCell[];
rows: number;
cols: number;
selectedCells: string[];
onCellClick: (cellId: string) => void;
onCellSelect: (cellId: string, isSelected: boolean) => void;
onCellReset: (cellId: string) => void;
onCellSplit: (id: string | null) => void;
}> = ({ data, rows, cols, selectedCells, onCellClick, onCellSelect, onCellReset, onCellSplit }) => {
const gridStyle = {
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 120px)`,
gap: '8px',
width: '100%'
};
const visibleCells = data.filter((cell) => cell.rowSpan > 0 && cell.colSpan > 0);
const mainMergeCellsIds = getMainCellsIds(data);
return (
<div style={gridStyle} className="bg-background rounded-lg border p-4">
{visibleCells.map((cell) => {
const isSelected = selectedCells.includes(cell.id);
return (
<Cell
key={cell.id}
cell={cell}
disabled={false}
isSelected={isSelected}
showSplitButton={mainMergeCellsIds.includes(cell.id)}
onClick={() => onCellClick(cell.id)}
onSelect={(isSelected) => onCellSelect(cell.id, isSelected)}
onReset={() => onCellReset(cell.id)}
onSplit={() => onCellSplit(cell.id)}
/>
);
})}
</div>
);
};
export default ModuleFormGrid;
@@ -0,0 +1,30 @@
import { CheckCircle, CloudDownload, OctagonAlert } from 'lucide-react';
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardHeading, CardTitle } from '#shared/ui/Card';
const ModuleGenerateAlert: React.FC<{
title: string;
message: React.ReactNode;
status: 'loading' | 'success' | 'error';
}> = ({ status, message, title }) => {
return (
<Card>
<CardContent>
<CardHeader className="flex-nowrap border-b-transparent p-0">
<span className="mb-auto shrink-0">
{status === 'loading' && <CloudDownload className="size-5 animate-bounce" />}
{status === 'success' && <CheckCircle className="text-primary size-5" />}
{status === 'error' && <OctagonAlert className="text-destructive size-5" />}
</span>
<CardHeading className="mb-auto grow">
<CardTitle>{title}</CardTitle>
<CardDescription>{message}</CardDescription>
</CardHeading>
</CardHeader>
</CardContent>
</Card>
);
};
export default ModuleGenerateAlert;
@@ -0,0 +1,18 @@
import { toast } from 'sonner';
import ModuleGenerateAlert from './ModuleGenerateAlert';
export function fireModuleGenerateAlert(params: {
id: string;
title: string;
message: React.ReactNode;
status: 'loading' | 'success' | 'error';
}) {
const { id, ...props } = params;
toast.custom(() => <ModuleGenerateAlert {...props} />, {
id: id,
classNames: { content: 'w-80' },
duration: Infinity,
position: 'bottom-right'
});
}
@@ -0,0 +1 @@
export * from './fireModuleGenerateAlert';
@@ -0,0 +1,59 @@
import { AlertCircle } from 'lucide-react';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Badge } from '#shared/ui/Badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '#shared/ui/Card';
export default function DatatableSection() {
return (
<Card>
<CardHeader className="py-5">
<CardTitle>Frontend Datatable</CardTitle>
<CardDescription>Configure the generated list page and table columns</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<article className="prose text-foreground max-w-none">
<p>
Enable <Badge>Datatable</Badge> to generate the module list page, data table, and menu
entry. Database columns provide the initial configuration.
</p>
<h3>Column configuration</h3>
<p>Select a column heading to edit its behavior. Each column can be:</p>
<ul>
<li>renamed for display;</li>
<li>hidden from the generated table;</li>
<li>enabled or disabled for sorting;</li>
<li>rendered as a badge;</li>
<li>given a copy-to-clipboard action.</li>
</ul>
<h3>Generated behavior</h3>
<p>
<code>view/widgets/DataTable.tsx</code> uses the module list query with server-side
pagination, sorting, and debounced search. The action column includes delete behavior
and includes create/edit behavior when <Badge>Form</Badge> is also selected.
</p>
<p>
<code>pages/ListPage/index.tsx</code> applies the view permission gate and composes the
table. API hooks are imported directly from <code>api/list.ts</code>,{' '}
<code>api/delete.ts</code>, and other operation files; the API directory has no barrel
file.
</p>
</article>
<Alert variant="info" appearance="light">
<AlertIcon>
<AlertCircle />
</AlertIcon>
<AlertContent>
<AlertTitle>Review generated cell rendering</AlertTitle>
<AlertDescription>
Database metadata cannot determine domain formatting. Add currency, localized dates,
status labels, relations, and custom actions after generation.
</AlertDescription>
</AlertContent>
</Alert>
</CardContent>
</Card>
);
}
@@ -0,0 +1,66 @@
import { Badge } from '#shared/ui/Badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '#shared/ui/Card';
export default function FormSection() {
return (
<Card>
<CardHeader className="py-5">
<CardTitle>Frontend Form</CardTitle>
<CardDescription>
Configure create and update UI using the generated form grid
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<article className="prose text-foreground max-w-none">
<p>
Enable <Badge>Form</Badge> to generate a create/update form and its manage trigger. The
form can be displayed in a default, medium, or fullscreen dialog, or in a sheet from any
side.
</p>
<h3>Grid configuration</h3>
<p>
Choose the number of rows and columns, place fields into cells, and merge cells when a
control needs more horizontal or vertical space. Use Preview to verify the layout before
generating files.
</p>
<h3>Field configuration</h3>
<ul>
<li>Database column used as the submitted field name.</li>
<li>
Control type, such as text, select, checkbox, date, file, editor, or color input.
</li>
<li>Visible label and placeholder.</li>
<li>Required or optional validation.</li>
<li>Options for choice-based controls.</li>
</ul>
<h3>Generated files</h3>
<ul>
<li>
<code>view/forms/MainForm/form.ts</code> — schema, types, defaults, and entity-to-form
mapping.
</li>
<li>
<code>view/forms/MainForm/Component.tsx</code> — React Hook Form UI.
</li>
<li>
<code>view/forms/MainForm/submit.ts</code> — direct create/update API imports, error
mapping, and cache invalidation.
</li>
<li>
<code>view/widgets/ManageTrigger.tsx</code> — detailed query and dialog/sheet
workflow.
</li>
</ul>
<p>
When both <Badge>Form</Badge> and <Badge>Datatable</Badge> are enabled, create and edit
actions are added to the generated table automatically.
</p>
</article>
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
import { AlertCircle } from 'lucide-react';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Badge } from '#shared/ui/Badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '#shared/ui/Card';
export default function MetadataSection() {
return (
<Card>
<CardHeader className="py-5">
<CardTitle>Metadata and Assets</CardTitle>
<CardDescription>Select the source table, module name, and generated files</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<article className="prose text-foreground max-w-none">
<p>
Select an existing database table first. Its columns initialize the form and datatable
configuration, while the proposed module name is converted to <Badge>camelCase</Badge>.
</p>
<p>
The module name must start with a lowercase letter and contain only letters and numbers.
It is reused for folder names, route segments, module identity, and permission slugs.
</p>
<h3>Backend assets</h3>
<ul>
<li>
<strong>Model</strong> — Lucid model with mapped columns, searchable fields, inferred
relationships, and password hashing when applicable.
</li>
<li>
<strong>Controller</strong> — authenticated resource controller and controller test.
</li>
<li>
<strong>Routes</strong> — authenticated REST resource routes.
</li>
<li>
<strong>Validator</strong> — separate Vine validators for create and update requests.
</li>
<li>
<strong>Policy</strong> — Bouncer policy, permission configuration, and policy
registry entry.
</li>
<li>
<strong>API</strong> — API controller, API validators, routes, Swagger schema
metadata, and API test.
</li>
</ul>
<h3>Frontend assets</h3>
<ul>
<li>
<strong>Form</strong> — form definition, React component, create/update submission,
and manage dialog or sheet.
</li>
<li>
<strong>Datatable</strong> — list table, route-level list page, actions, pagination,
sorting, filtering, and module menu entry.
</li>
</ul>
</article>
<Alert variant="info" appearance="light">
<AlertIcon>
<AlertCircle />
</AlertIcon>
<AlertContent>
<AlertTitle>Select Model when generating API files</AlertTitle>
<AlertDescription>
API generation imports the generated model to derive Swagger response metadata. The
default backend selection already includes both assets.
</AlertDescription>
</AlertContent>
</Alert>
</CardContent>
</Card>
);
}
@@ -0,0 +1,61 @@
import { AlertTriangle } from 'lucide-react';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '#shared/ui/Card';
export default function OverviewSection() {
return (
<Card>
<CardHeader className="py-5">
<CardTitle>Module Generator</CardTitle>
<CardDescription>How frontend configuration becomes backend and React code</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid justify-center">
<img
src={toAbsoluteUrl('/public/media/illustrations/11.svg')}
className="h-42 max-h-42 dark:hidden"
alt="Module generator illustration"
/>
<img
src={toAbsoluteUrl('/public/media/illustrations/11-dark.svg')}
className="light:hidden h-42 max-h-42"
alt="Module generator illustration"
/>
</div>
<article className="prose text-foreground max-w-none">
<p>
The module generator creates an AdonisJS backend module and a matching React admin
module from an existing PostgreSQL table.
</p>
<p>
The frontend reads table metadata, lets you select backend assets, and optionally
configures a form and datatable. It then builds the dynamic TSX source and sends one
generation payload to the backend.
</p>
<p>
The backend inspects the database schema, renders Handlebars templates, copies the
frontend module template, and writes the configured files into both workspaces.
Generated module manifests and routes are discovered automatically when the apps
restart.
</p>
</article>
<Alert variant="warning" appearance="light">
<AlertIcon>
<AlertTriangle />
</AlertIcon>
<AlertContent>
<AlertTitle>Generation writes directly to the repository</AlertTitle>
<AlertDescription>
There is no preview, transaction, or rollback. Use a new module name, inspect the Git
diff, and review generated validation, permissions, relationships, and routes before
keeping the result.
</AlertDescription>
</AlertContent>
</Alert>
</CardContent>
</Card>
);
}
@@ -0,0 +1,107 @@
import { AlertCircle, AlertTriangle } from 'lucide-react';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '#shared/ui/Card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '#shared/ui/Table';
export default function TutorialSection() {
return (
<Card>
<CardHeader className="py-5">
<CardTitle>Generate a Module</CardTitle>
<CardDescription>Recommended generation and verification workflow</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<article className="prose prose-base text-foreground max-w-none">
<ol>
<li>
Create and migrate the PostgreSQL table before opening the generator. The table must
exist in the public schema.
</li>
<li>Select the table. The generator loads its columns and proposes a module name.</li>
<li>
Review the module name. Use singular <code>camelCase</code> without spaces,
underscores, or punctuation.
<Table className="mt-3">
<TableHeader>
<TableRow>
<TableHead>Valid</TableHead>
<TableHead>Invalid</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<code>user</code>
</TableCell>
<TableCell>
<code>users</code>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<code>userRole</code>
</TableCell>
<TableCell>
<code>user_role</code>
</TableCell>
</TableRow>
</TableBody>
</Table>
</li>
<li>
Select backend assets. Keep <strong>Model</strong> enabled when generating{' '}
<strong>API</strong> files.
</li>
<li>
If <strong>Form</strong> is enabled, configure the grid, field controls, validation,
options, container type, and preview.
</li>
<li>
If <strong>Datatable</strong> is enabled, remove unnecessary columns and configure
labels, sorting, badge rendering, and copy behavior.
</li>
<li>Submit once and wait for the success notification before navigating away.</li>
<li>
Inspect the Git diff, review the generated code, then run backend and admin builds.
</li>
</ol>
</article>
<Alert variant="warning" appearance="light">
<AlertIcon>
<AlertTriangle />
</AlertIcon>
<AlertContent>
<AlertTitle>Do not generate over an existing module</AlertTitle>
<AlertDescription>
The generator writes directly into both workspaces and may overwrite files. There is
no automatic rollback.
</AlertDescription>
</AlertContent>
</Alert>
<Alert variant="info" appearance="light">
<AlertIcon>
<AlertCircle />
</AlertIcon>
<AlertContent>
<AlertTitle>If the generated module or menu is not visible</AlertTitle>
<AlertDescription className="prose text-foreground max-w-none">
<ul>
<li>Restart the backend and admin app so generated routes and manifests reload.</li>
<li>
Compare frontend permission constants in{' '}
<code>apps/admin/src/modules/&lt;name&gt;/config/meta.ts</code> with backend
permissions in{' '}
<code>apps/backend/modules/&lt;ModuleName&gt;/config/permissions.ts</code>.
</li>
<li>Confirm the current admin role owns the generated view permission.</li>
<li>Check that the module manifest exposes its menu and routes.</li>
</ul>
</AlertDescription>
</AlertContent>
</Alert>
</CardContent>
</Card>
);
}
@@ -0,0 +1,60 @@
import { Code2 } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@crux-kit/utils';
import { Button } from '#shared/ui/Button';
import { Card, CardContent, CardDescription, CardHeader, CardHeading, CardTitle, CardToolbar } from '#shared/ui/Card';
import { CopyButton } from '#shared/ui/CopyButton';
import { Sheet, SheetBody, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '#shared/ui/Sheet';
const SandboxShowcase: React.FC<
React.PropsWithChildren<{
title: string;
description: string;
code: string;
className?: string;
}>
> = ({ title, description, code, children, className }) => {
const intl = useIntl();
return (
<Card className="min-w-0">
<CardHeader className={cn('py-2', className)}>
<CardHeading>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeading>
<CardToolbar>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Code2 /> {intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.USAGE' })}
</Button>
</SheetTrigger>
<SheetContent className="sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
{intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.USAGE_DESCRIPTION' })}
</SheetDescription>
</SheetHeader>
<SheetBody className="min-h-0 overflow-auto">
<div className="border-border bg-muted/50 relative rounded-lg border">
<CopyButton copyText={code} className="absolute inset-e-2 top-2" />
<pre className="overflow-x-auto p-4 pe-12 text-xs leading-relaxed">
<code>{code}</code>
</pre>
</div>
</SheetBody>
</SheetContent>
</Sheet>
</CardToolbar>
</CardHeader>
<CardContent className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-3">{children}</div>
</CardContent>
</Card>
);
};
export default SandboxShowcase;
@@ -0,0 +1,20 @@
import { ExternalLink } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Button, type ButtonProps } from '#shared/ui/Button';
const ToDocsButton: React.FC<ButtonProps & { to: string }> = ({ to, children }) => {
const intl = useIntl();
return (
<Button type="button" variant="primary" appearance="default" mode="link" asChild>
<Link target="_blank" to={to}>
{children ?? intl.formatMessage({ id: 'DEVTOOLS.DOCS' })}
<ExternalLink />
</Link>
</Button>
);
};
export default ToDocsButton;
@@ -0,0 +1,60 @@
import { LoaderCircleIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import { ModuleGeneratorConfig } from '#devtools/entities/ModuleGeneratorTypes';
import ToDocsButton from '#devtools/view/components/ToDocsButton';
import { FormElement } from '#shared/forms/composed';
import { Button } from '#shared/ui/Button';
import { Form } from '#shared/ui/form';
import { Separator } from '#shared/ui/Separator';
import { useModuleGeneratorForm } from './form';
import DataTableSection from './sections/DataTableSection';
import FormSection from './sections/FormSection';
import MetadataSection from './sections/MetadataSection';
export const ModuleGeneratorForm: React.FC<{
isPending: boolean;
onSubmit: (values: ModuleGeneratorConfig, reset: () => void) => void;
}> = ({ isPending, onSubmit }) => {
const intl = useIntl();
const form = useModuleGeneratorForm();
const generate = form.watch('generate');
const handleSubmit = form.handleSubmit((values) => {
onSubmit(values, () => form.reset());
});
return (
<div className="mx-auto w-full">
<Form {...form}>
<FormElement onSubmit={handleSubmit}>
<MetadataSection />
{generate.includes(ModuleAsset.FORM) && <FormSection />}
{generate.includes(ModuleAsset.DATATABLE) && <DataTableSection />}
<Separator />
<div className="flex justify-between gap-4">
<ToDocsButton to="/devtools/module-generator/docs?section=tutorial">
{intl.formatMessage({ id: 'DEVTOOLS.MODULE_GENERATOR_DOCS' })}
</ToDocsButton>
<Button type="submit" disabled={isPending}>
{isPending ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="size-4 animate-spin" />
{intl.formatMessage({ id: 'DEVTOOLS.STATE.GENERATING' })}...
</span>
) : (
intl.formatMessage({ id: 'DEVTOOLS.ACTION.GENERATE' })
)}
</Button>
</div>
</FormElement>
</Form>
</div>
);
};
@@ -0,0 +1,49 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useFormContext, useForm as useReactHookForm } from 'react-hook-form';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import type { ModuleGeneratorConfig, ModuleLayoutConfig, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
import { createGrid } from './lib/grid';
import { validationSchema } from './lib/validationSchemas';
const INITIAL_ROWS = 3;
const INITIAL_COLS = 1;
export const formInitialValues: ModuleLayoutConfig = {
cols: INITIAL_COLS,
rows: INITIAL_ROWS,
grid: createGrid(INITIAL_ROWS, INITIAL_COLS),
selectedCells: [],
size: 'dialog-md'
};
export const datatableInitialValues: ModuleTableConfig = {
columns: []
};
export const initialGenerateValue = [
ModuleAsset.CONTROLLER,
ModuleAsset.MODEL,
ModuleAsset.POLICY,
ModuleAsset.ROUTES,
ModuleAsset.VALIDATOR,
ModuleAsset.API
];
const initialValues: ModuleGeneratorConfig = {
name: '',
table: '',
generate: initialGenerateValue,
form: formInitialValues,
datatable: datatableInitialValues
};
export const useModuleGeneratorForm = () => {
return useReactHookForm<ModuleGeneratorConfig>({
resolver: zodResolver(validationSchema),
values: initialValues
});
};
export const useModuleGeneratorFormContext = () => {
return useFormContext<ModuleGeneratorConfig>();
};
@@ -0,0 +1 @@
export * from './Component';
@@ -0,0 +1,38 @@
import { v4 as uuid } from 'uuid';
import type { ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
export const createGrid = (rows: number, cols: number): ModuleGridCell[] => {
const cells: ModuleGridCell[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
cells.push(createCellTemplate(row, col));
}
}
return cells;
};
export const createCellTemplate = (row: number, col: number): ModuleGridCell => {
return {
id: uuid(),
row,
col,
rowSpan: 1,
colSpan: 1,
body: null,
isEmpty: true,
mergeTo: null
};
};
export function checkCanSplit(cells: ModuleGridCell[], selectedCells: string[]): boolean {
if (selectedCells.length !== 1) return false;
const selectedCellId = selectedCells[0];
const cell = cells.find((item) => item.id === selectedCellId);
if (!cell) return false;
return cell.colSpan > 1;
}
@@ -0,0 +1,80 @@
import z from 'zod';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
export const cellBodySchema = z.object({
dbColumn: z.string().trim().min(1, 'Required'),
type: z.nativeEnum(ModuleFieldType),
label: z.string().min(1, 'Required'),
placeholder: z.string(),
required: z.boolean(),
options: z.array(
z.object({
label: z.string().min(1, 'Required'),
value: z.string().min(1, 'Required'),
group: z.string().optional()
})
)
});
const gridCellSchema = z.object({
id: z.string(),
row: z.number(),
col: z.number(),
rowSpan: z.number(),
colSpan: z.number(),
body: cellBodySchema.nullable(),
isEmpty: z.boolean(),
mergeTo: z.string().nullable()
});
const metaSchema = z.object({
name: z.string().regex(/^[a-z][a-zA-Z0-9]*$/, {
message:
'Must be camelCase: start with lowercase and contain only letters or numbers, no spaces or underscores.'
}),
table: z.string().min(1, { message: 'Table is required' }),
generate: z.array(z.nativeEnum(ModuleAsset)).min(1)
});
const frontFormSchema = z.object({
form: z.object({
cols: z.number(),
rows: z.number(),
selectedCells: z.array(z.string()),
grid: z.array(gridCellSchema),
size: z.enum([
'dialog-default',
'dialog-md',
'dialog-fullscreen',
'sheet-top',
'sheet-right',
'sheet-bottom',
'sheet-left'
])
})
});
const datatableSchema = z.object({
datatable: z.object({
columns: z.array(
z.object({
label: z.string(),
value: z.string(),
hidden: z.boolean(),
isActionColumn: z.boolean(),
innerProps: z.object({
badge: z.boolean(),
sort: z.boolean(),
copy: z.boolean()
})
})
)
})
});
export const validationSchema = z
.object({})
.and(metaSchema)
.and(frontFormSchema)
.and(datatableSchema);
@@ -0,0 +1,80 @@
import { Plus } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { Badge } from '#shared/ui/Badge';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#shared/ui/Select';
import { useModuleGeneratorFormContext } from '../../../form';
import mapColumnsVisibility from '../lib/mapColumnsVisibility';
const AddColumnButton: React.FC = () => {
const intl = useIntl();
const { watch, setValue } = useModuleGeneratorFormContext();
const columns = watch('datatable.columns');
const [selected, setSelected] = React.useState<string>('');
const options = columns.filter((col) => col.hidden === true);
const handleSubmit = () => {
if (selected) {
setValue('datatable.columns', mapColumnsVisibility(columns, selected));
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="mono" mode="icon" size="sm">
<Plus />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.ADD_COLUMN' })}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody className="text-sm">
<React.Fragment>
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger>
<SelectValue
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.SELECT_COLUMN'
})}
/>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<Badge variant="warning" appearance="outline">
{option.label}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
</React.Fragment>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="button" onClick={handleSubmit}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CONFIRM' })}
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default AddColumnButton;
@@ -0,0 +1,30 @@
import { Trash } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { DropdownMenuItem } from '#shared/ui/DropdownMenu';
import { useModuleGeneratorFormContext } from '../../../form';
import mapColumnsVisibility from '../lib/mapColumnsVisibility';
const DeleteColumnButton: React.FC<{ target: string }> = ({ target }) => {
const intl = useIntl();
const { watch, setValue } = useModuleGeneratorFormContext();
const selectedColumns = watch('datatable.columns');
const removeColumn = React.useCallback(
(colName: string) => {
setValue('datatable.columns', mapColumnsVisibility(selectedColumns, colName));
},
[setValue, selectedColumns]
);
return (
<DropdownMenuItem variant="destructive" onClick={() => removeColumn(target)}>
<Trash className="size-3.5!" />
<span className="grow">{intl.formatMessage({ id: 'DEVTOOLS.ACTION.DELETE' })}</span>
</DropdownMenuItem>
);
};
export default DeleteColumnButton;
@@ -0,0 +1,55 @@
import { X } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
const handleSubmit = () => {
toast.success('Deleted');
};
const DeleteRowButton: React.FC = () => {
const intl = useIntl();
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" shape="default" mode="icon">
<X />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CONFIRM' })}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody className="text-sm">
<React.Fragment>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION' }, { target: 'row' })}
<span className="ml-1">
{intl.formatMessage({
id: 'DEVTOOLS.ACTION.REMOVE_ITEM_QUESTION_DESC'
})}
</span>
</React.Fragment>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="destructive" onClick={handleSubmit}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.REMOVE' })}
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteRowButton;
@@ -0,0 +1,22 @@
import { SquarePen } from 'lucide-react';
import React from 'react';
import { ModuleFormContainer, type ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { FormPreviewTrigger } from '#devtools/view/widgets/ModuleFormPreview';
import { Button } from '#shared/ui/Button';
const EditButton: React.FC<{
grid: ModuleGridCell[];
cols: number;
rows: number;
size: ModuleFormContainer;
}> = (props) => {
return (
<FormPreviewTrigger {...props}>
<Button type="button" variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</FormPreviewTrigger>
);
};
export default EditButton;
@@ -0,0 +1,184 @@
import { Info, LetterText } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDisclosure } from '@crux-kit/hooks';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '#shared/ui/Alert';
import { Badge } from '#shared/ui/Badge';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { DropdownMenuItem } from '#shared/ui/DropdownMenu';
import { Input } from '#shared/ui/Input';
import { Label } from '#shared/ui/Label';
import { Switch } from '#shared/ui/Switch';
import { useModuleGeneratorFormContext } from '../../../form';
interface Values {
label: string;
badge: boolean;
sort: boolean;
copy: boolean;
}
const EditColumnButton: React.FC<{ target: string }> = ({ target }) => {
const intl = useIntl();
const { isOpen, toggle, open } = useDisclosure();
const { watch, setValue } = useModuleGeneratorFormContext();
const columns = watch('datatable.columns');
const current = columns.find((col) => col.value === target);
const handleSubmit = (values: Values) => {
if (values && current) {
setValue(
'datatable.columns',
columns.map((col) => {
if (col.value === current.value) {
return {
...col,
label: values.label,
innerProps: {
...col.innerProps,
...values
}
};
}
return col;
})
);
}
};
return (
<Dialog open={isOpen} onOpenChange={toggle}>
<DialogTrigger asChild>
<DropdownMenuItem
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
open();
}}
>
<LetterText className="size-3.5!" />
<span className="grow">{intl.formatMessage({ id: 'DEVTOOLS.ACTION.EDIT' })}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{intl.formatMessage({ id: 'DEVTOOLS.ACTION.EDIT' })}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
{isOpen && (
<Form
initialValue={{
label: current?.label || current?.value || '',
badge: current?.innerProps.badge ?? false,
sort: current?.innerProps.sort ?? false,
copy: current?.innerProps.copy ?? false
}}
onSubmit={handleSubmit}
/>
)}
</DialogContent>
</Dialog>
);
};
const Form: React.FC<{
initialValue: Values;
onSubmit: (values: Values) => void;
}> = ({ initialValue, onSubmit }) => {
const intl = useIntl();
const [label, setLabel] = React.useState<string>(() => initialValue.label);
const [badge, setBadge] = React.useState<boolean>(() => initialValue.badge);
const [sort, setSort] = React.useState<boolean>(() => initialValue.sort);
const [copy, setCopy] = React.useState<boolean>(() => initialValue.copy);
return (
<React.Fragment>
<DialogBody className="space-y-3 text-sm">
<div className="flex flex-col gap-2">
<Label className="text-foreground font-medium" htmlFor="column-label">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.LABEL' })}
</Label>
<Input
type="text"
id="column-label"
value={label}
placeholder={intl.formatMessage({ id: 'DEVTOOLS.MODULES.ENTER_FIELD_LABEL' })}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div className="flex grow items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<Label>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_BADGE' })}
<Badge size="sm" variant="primary" className="mx-2">
EXAMPLE
</Badge>
</Label>
<div className="-mt-0.5 text-xs font-normal">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_BADGE_DESC' })}
</div>
</div>
<Switch size="sm" checked={badge} onCheckedChange={setBadge} />
</div>
<div className="flex grow items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<Label>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_SORTING' })}</Label>
<div className="-mt-0.5 text-xs font-normal">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_SORTING_DESC' })}
</div>
</div>
<Switch size="sm" checked={sort} onCheckedChange={setSort} />
</div>
<div className="flex grow items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<Label>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON' })}</Label>
<div className="-mt-0.5 text-xs font-normal">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.ADD_COPY_BUTTON_DESC' })}
</div>
</div>
<Switch size="sm" checked={copy} onCheckedChange={setCopy} />
</div>
<Alert variant="info" appearance="light">
<AlertIcon>
<Info />
</AlertIcon>
<AlertContent>
<AlertTitle>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.IMPORTANT' })}
</AlertTitle>
<AlertDescription>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MANUAL.IMPORTANT_MESSAGE' })}
</AlertDescription>
</AlertContent>
</Alert>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="button" onClick={() => onSubmit({ badge, label, sort, copy })}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CONFIRM' })}
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</DialogClose>
</DialogFooter>
</React.Fragment>
);
};
export default EditColumnButton;
@@ -0,0 +1,155 @@
import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table';
import { SearchIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import ToDocsButton from '#devtools/view/components/ToDocsButton';
import OffsetSeparator from '#devtools/view/ui/OffsetSeparator';
import { Card, CardHeader, CardTable, CardTitle } from '#shared/ui/Card';
import { Input, InputWrapper } from '#shared/ui/Input';
import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
import { Skeleton } from '#shared/ui/Skeleton';
import { DataGrid, DataGridColumnHeader, DataGridTable } from '#shared/widgets/DataGrid';
import { useModuleGeneratorFormContext } from '../../form';
import AddColumnButton from './components/AddColumnButton';
import DeleteColumnButton from './components/DeleteColumnButton';
import DeleteRowButton from './components/DeleteRowButton';
import EditButton from './components/EditButton';
import EditColumnButton from './components/EditColumnButton';
import createTableRows from './lib/createTableRows';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Data = any;
const ROWS_COUNT = 4;
const DataTableSection: React.FC = () => {
const intl = useIntl();
const { watch } = useModuleGeneratorFormContext();
const generate = watch('generate');
const grid = watch('form.grid');
const cols = watch('form.cols');
const rows = watch('form.rows');
const dialogSize = watch('form.size');
const formColumns = watch('datatable.columns');
const list = createTableRows(formColumns, ROWS_COUNT);
const showEditButton = generate.includes(ModuleAsset.FORM);
const showAddColumnsButton = formColumns.find((col) => col.hidden === true);
const columns = React.useMemo<ColumnDef<Data>[]>(() => {
const prepared: ColumnDef<Data>[] = [];
formColumns.forEach((col) => {
if (col.hidden) return;
if (col.isActionColumn)
prepared.push({
id: col.value,
header: ({ column }) => (
<DataGridColumnHeader
items={
<React.Fragment>
<EditColumnButton target={col.value} />
<DeleteColumnButton target={col.value} />
</React.Fragment>
}
title={col.label}
column={column}
/>
),
cell: () => (
<div className="flex items-center justify-end gap-2">
{showEditButton && (
<EditButton cols={cols} grid={grid} rows={rows} size={dialogSize} />
)}
<DeleteRowButton />
</div>
),
enableSorting: false
});
else {
prepared.push({
id: col.value,
accessorFn: (row) => row[col.value],
cell: () => <Skeleton className="h-4" />,
header: ({ column }) => (
<DataGridColumnHeader
items={
<React.Fragment>
<EditColumnButton target={col.value} />
<DeleteColumnButton target={col.value} />
</React.Fragment>
}
title={col.label}
column={column}
/>
),
enableSorting: false
});
}
});
return prepared;
}, [formColumns, showEditButton, cols, dialogSize, grid, rows]);
const table = useReactTable({
columns,
autoResetPageIndex: false,
enableMultiSort: false,
data: list,
getRowId: (row: Data) => row.id,
getCoreRowModel: getCoreRowModel()
});
return (
<React.Fragment>
<OffsetSeparator
action={<ToDocsButton to="/devtools/module-generator/docs?section=datatable" />}
>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.DATATABLE' })}
</OffsetSeparator>
<DataGrid
table={table}
recordCount={ROWS_COUNT}
tableLayout={{
columnsDraggable: true,
columnsResizable: false,
width: 'auto',
columnsPinnable: false,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
<SearchIcon className="size-4" />
<Input
type="text"
placeholder={intl.formatMessage({ id: 'DEVTOOLS.ACTION.SEARCH' })}
/>
</InputWrapper>
</CardTitle>
{showAddColumnsButton && <AddColumnButton />}
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
</Card>
</DataGrid>
</React.Fragment>
);
};
export default DataTableSection;
@@ -0,0 +1,22 @@
function createObjectFromKeys(keys: string[]): Record<string, string> {
return keys.reduce(
(acc, key) => {
acc[key] = 'TEXT';
return acc;
},
{} as Record<string, string>
);
}
export default function createTableRows(columns: Array<{ label: string }>, number: number = 10) {
const fields = columns.map((col) => col.label);
const rows = [];
for (let i = 0; i < number; i++) {
const data = createObjectFromKeys(fields);
rows.push({ ...data, id: i });
}
return rows;
}
@@ -0,0 +1,16 @@
import type { ModuleTableColumn } from '#devtools/entities/ModuleGeneratorTypes';
export default function mapColumnsBadge(columns: ModuleTableColumn[], target: string) {
return columns.map((column) => {
if (column.value === target) {
return {
...column,
innerProps: {
...column.innerProps,
badge: !column.innerProps.badge
}
};
}
return column;
});
}
@@ -0,0 +1,15 @@
import type { ModuleTableColumn } from '#devtools/entities/ModuleGeneratorTypes';
export default function mapColumnsLabel(
columns: ModuleTableColumn[],
target: string,
label: string
) {
return columns.map((col) => {
if (col.value === target) {
return { ...col, value: col.value, label: label };
}
return col;
});
}
@@ -0,0 +1,10 @@
import type { ModuleTableColumn } from '#devtools/entities/ModuleGeneratorTypes';
export default function mapColumnsVisibility(columns: ModuleTableColumn[], target: string) {
return columns.map((column) => {
if (column.value === target) {
return { ...column, hidden: !column.hidden };
}
return column;
});
}
@@ -0,0 +1,191 @@
import { ArrowUpDown, ChevronDown, Columns, Merge, Minus, Plus, RotateCcw, Rows, Split, View } from 'lucide-react';
import { useIntl } from 'react-intl';
import { ModuleFormContainer, ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { FormPreviewTrigger } from '#devtools/view/widgets/ModuleFormPreview';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
import { Separator } from '#shared/ui/Separator';
const FormToolbar: React.FC<{
rows: number;
cols: number;
dialogSize: ModuleFormContainer;
grid: ModuleGridCell[];
onAddRow: () => void;
onAddColumn: () => void;
onRemoveRow: () => void;
onRemoveColumn: () => void;
onMergeCells: () => void;
onSplitCell: () => void;
onReset: () => void;
onSwap: () => void;
onDialogSizeChange: (value: ModuleFormContainer) => void;
canMerge: boolean;
canSplit: boolean;
canRemoveRow: boolean;
canRemoveColumn: boolean;
canAddRow: boolean;
canAddColumn: boolean;
canSwap: boolean;
selectedCount: number;
}> = (props) => {
const {
onAddRow,
onAddColumn,
onRemoveRow,
onRemoveColumn,
onMergeCells,
onSplitCell,
onReset,
onSwap,
onDialogSizeChange,
dialogSize,
canMerge,
canSplit,
canRemoveRow,
canRemoveColumn,
selectedCount,
canAddColumn,
canAddRow,
canSwap,
rows,
cols,
grid
} = props;
const intl = useIntl();
return (
<div className="bg-muted flex flex-wrap items-center gap-2 rounded-lg border p-4">
<div className="flex items-center gap-1">
<Rows className="h-4 w-4" />
<span className="inline-block w-5 text-center">{rows}</span>
<div>
<Button
type="button"
onClick={onAddRow}
disabled={!canAddRow}
size="sm"
mode="icon"
variant="ghost"
>
<Plus className="h-4 w-4" />
</Button>
<Button
type="button"
onClick={onRemoveRow}
size="sm"
mode="icon"
variant="ghost"
disabled={!canRemoveRow}
>
<Minus className="h-4 w-4" />
</Button>
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-1">
<Columns className="h-4 w-4" />
<span className="inline-block w-5 text-center">{cols}</span>
<div>
<Button
type="button"
disabled={!canAddColumn}
size="sm"
mode="icon"
variant="ghost"
onClick={onAddColumn}
>
<Plus className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
mode="icon"
variant="ghost"
disabled={!canRemoveColumn}
onClick={onRemoveColumn}
>
<Minus className="h-4 w-4" />
</Button>
</div>
</div>
<Separator orientation="vertical" className="h-6" />
<Button type="button" size="sm" variant="mono" disabled={!canSwap} onClick={onSwap}>
<ArrowUpDown className="mr-1 h-4 w-4" />
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.SWAP' })}
</Button>
<Button type="button" size="sm" variant="outline" disabled={!canMerge} onClick={onMergeCells}>
<Merge className="mr-1 h-4 w-4" />
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.MERGE' })}
<span className="inline-block w-5">({selectedCount})</span>
</Button>
<Button type="button" size="sm" variant="outline" disabled={!canSplit} onClick={onSplitCell}>
<Split className="mr-1 h-4 w-4" />
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.SPLIT' })}
</Button>
<Separator orientation="vertical" className="h-6" />
<Button type="button" onClick={onReset} size="sm" variant="outline">
<RotateCcw className="mr-1 h-4 w-4" />
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.RESET' })}
</Button>
<FormPreviewTrigger cols={cols} rows={rows} size={dialogSize} grid={grid}>
<Button variant="dashed" type="button" size="sm">
<View className="mr-1 h-4 w-4" />
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.PREVIEW' })}
</Button>
</FormPreviewTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="outline">
{dialogSize.startsWith('sheet-')
? `${intl.formatMessage({ id: 'DEVTOOLS.MODULES.SHEET' })}: ${dialogSize.replace('sheet-', '')}`
: `${intl.formatMessage({ id: 'DEVTOOLS.MODULES.DIALOG' })}: ${dialogSize.replace('dialog-', '')}`}
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.DIALOG' })}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={dialogSize}
onValueChange={(value) => onDialogSizeChange(value as ModuleFormContainer)}
>
<DropdownMenuRadioItem value="dialog-default">default</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dialog-md">md</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dialog-fullscreen">fullscreen</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.SHEET' })}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={dialogSize}
onValueChange={(value) => onDialogSizeChange(value as ModuleFormContainer)}
>
{(['top', 'right', 'bottom', 'left'] as const).map((side) => (
<DropdownMenuRadioItem key={side} value={`sheet-${side}`}>
{intl.formatMessage({ id: `DEVTOOLS.MODULES.SHEET_SIDE.${side.toUpperCase()}` })}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default FormToolbar;
@@ -0,0 +1,229 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, X } from 'lucide-react';
import React from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useIntl } from 'react-intl';
import { uid } from '@crux-kit/utils';
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
import { ModuleFieldConfig } from '#devtools/entities/ModuleGeneratorTypes';
import { FormElement, FormGrid, InputCombobox, InputSwitch, InputText } from '#shared/forms/composed';
import { Badge } from '#shared/ui/Badge';
import { Button } from '#shared/ui/Button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '#shared/ui/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#shared/ui/Select';
import { cellBodySchema } from '../../../lib/validationSchemas';
import { FIELD_DIALOG_FORM_ID, FIELD_OPTIONS_LIST, FIELDS_WITH_GROUPS, FIELDS_WITH_OPTIONS, FIELDS_WITH_PLACEHOLDER } from '../config';
const ManageFieldForm: React.FC<{
onSubmit: (values: ModuleFieldConfig) => void;
initialData?: ModuleFieldConfig;
tableColumns: Array<{ label: string; value: string }>;
disabledFields: string[];
}> = ({ tableColumns, onSubmit, initialData, disabledFields }) => {
const intl = useIntl();
const form = useForm<ModuleFieldConfig>({
resolver: zodResolver(cellBodySchema),
values: initialData
? {
type: initialData.type,
label: initialData.label,
placeholder: initialData.placeholder,
required: initialData.required,
options: initialData.options,
dbColumn: initialData.dbColumn
}
: {
label: 'Label example',
options: [],
placeholder: '',
required: false,
type: ModuleFieldType.TEXT,
dbColumn: ''
}
});
const { fields, append, remove } = useFieldArray<ModuleFieldConfig, 'options'>({
control: form.control,
name: 'options'
});
const type = form.watch('type');
const dbColumn = form.watch('dbColumn');
const setValue = form.setValue;
const updateLabel = React.useCallback(
(value: string) => {
setValue('label', value, {
shouldTouch: false,
shouldValidate: false
});
},
[setValue]
);
React.useEffect(() => {
if (dbColumn.length === 0) return;
const label = String(dbColumn.replace(/_/g, ' ')).replace(/\b\w/g, (character) =>
character.toUpperCase()
);
updateLabel(label);
}, [dbColumn, updateLabel]);
const showOptionsField = FIELDS_WITH_OPTIONS.includes(type);
const showPlaceholderField = FIELDS_WITH_PLACEHOLDER.includes(type);
const showGroupField = FIELDS_WITH_GROUPS.includes(type);
const handleSubmit: React.SubmitEventHandler<HTMLFormElement> = (event) => {
event.stopPropagation();
form.handleSubmit(onSubmit)(event);
};
return (
<Form {...form}>
<FormElement id={FIELD_DIALOG_FORM_ID} className="space-y-4" onSubmit={handleSubmit}>
<FormGrid columns={1}>
<FormField
control={form.control}
name="dbColumn"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.COLUMN' })}
<span className="ml-1 text-red-500">*</span>
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={field.disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.SELECT_COLUMN'
})}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{tableColumns.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={disabledFields.includes(option.value)}
>
<Badge variant="warning" appearance="outline" size="md">
{option.label}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<InputCombobox
required
name="type"
label={intl.formatMessage({ id: 'DEVTOOLS.MODULES.FIELD_TYPE' })}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.SELECT_FIELD_TYPE'
})}
inputPlaceholder={intl.formatMessage({
id: 'DEVTOOLS.ACTION.SEARCH'
})}
groups={FIELD_OPTIONS_LIST}
/>
<InputText
name="label"
required
label={intl.formatMessage({ id: 'DEVTOOLS.MODULES.LABEL' })}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_FIELD_LABEL'
})}
/>
{showPlaceholderField && (
<InputText
name="placeholder"
label={intl.formatMessage({ id: 'DEVTOOLS.MODULES.PLACEHOLDER' })}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_FIELD_PLACEHOLDER'
})}
/>
)}
{showOptionsField && (
<div className="max-h-48 space-y-2 overflow-auto">
{fields.map((field, index) => (
<div className="flex items-start justify-between gap-2.5" key={field.id}>
<div className="flex grow items-start gap-2.5">
<span className="w-4 leading-8.5">{index + 1}</span>
<InputText
name={`options.${index}.label`}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_OPTION_LABEL'
})}
/>
<InputText
name={`options.${index}.value`}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_OPTION_VALUE'
})}
/>
{showGroupField && (
<InputText
name={`options.${index}.group`}
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_OPTION_GROUP'
})}
/>
)}
</div>
<Button variant="ghost" mode="icon" onClick={() => remove(index)}>
<X />
</Button>
</div>
))}
<div className="flex justify-end">
<Button
type="button"
variant="mono"
mode="icon"
size="sm"
shape="default"
onClick={() => append({ label: '', value: '', group: '' })}
>
<Plus />
</Button>
</div>
</div>
)}
<InputSwitch
name="required"
label={intl.formatMessage({ id: 'DEVTOOLS.MODULES.REQUIRED_FIELD' }, {})}
description={intl.formatMessage(
{ id: 'DEVTOOLS.MODULES.ADD_RED_STAR_MESSAGE' },
{
children: (
<span key={uid()} className="mx-1 text-red-500">
*
</span>
)
}
)}
/>
</FormGrid>
</FormElement>
</Form>
);
};
export default ManageFieldForm;
@@ -0,0 +1,62 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { ModuleFieldConfig } from '#devtools/entities/ModuleGeneratorTypes';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '#shared/ui/Dialog';
import { FIELD_DIALOG_FORM_ID } from '../config';
import ManageFieldForm from './ManageFieldForm';
const ManageFormFieldDialog: React.FC<{
tableColumns: Array<{ label: string; value: string }>;
disabledFields: string[];
isOpen: boolean;
onClose: () => void;
onSave: (values: ModuleFieldConfig) => void;
initialData?: ModuleFieldConfig;
}> = ({ tableColumns, isOpen, onClose, onSave, disabledFields, initialData }) => {
const intl = useIntl();
const handleSave = (values: ModuleFieldConfig) => {
onSave(values);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{intl.formatMessage({
id: 'DEVTOOLS.MODULES.CONFIGURE_FORM_FIELD'
})}
</DialogTitle>
<DialogDescription className="sr-only">
{intl.formatMessage({
id: 'DEVTOOLS.MODULES.CONFIGURE_FORM_FIELD'
})}
</DialogDescription>
</DialogHeader>
<DialogBody>
<ManageFieldForm
disabledFields={disabledFields}
tableColumns={tableColumns}
initialData={initialData}
onSubmit={handleSave}
/>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</DialogClose>
<Button type="submit" form={FIELD_DIALOG_FORM_ID}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.SAVE' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ManageFormFieldDialog;
@@ -0,0 +1,81 @@
import { uid } from '@crux-kit/utils';
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
export const FIELD_DIALOG_FORM_ID = uid();
const fieldDateOptions = [
{ value: ModuleFieldType.DATE_PICKER_RANGE, label: 'Date picker(Range)', mode: 'range' },
{ value: ModuleFieldType.DATE_PICKER_SINGLE, label: 'Date picker(Single)', mode: 'single' },
{ value: ModuleFieldType.CALENDAR, label: 'Calendar', mode: 'small' },
{ value: ModuleFieldType.DATE_INPUT, label: 'Date input', mode: null },
{ value: ModuleFieldType.TIME_INPUT_ISO, label: 'Time input (ISO)', mode: null },
{ value: ModuleFieldType.TIME_INPUT_HMS, label: 'Time input (HH:MM:SS)', mode: null }
];
const fieldTextOptions = [
{ value: ModuleFieldType.TEXT, label: 'Text Input', mode: null },
{ value: ModuleFieldType.EMAIL, label: 'Email Input', mode: null },
{ value: ModuleFieldType.PHONE, label: 'Phone Input', mode: null },
{ value: ModuleFieldType.NUMBER, label: 'Number Input', mode: null },
{ value: ModuleFieldType.NUMBER_COUNTER, label: 'Number Counter', mode: null },
{ value: ModuleFieldType.PASSWORD, label: 'Password Input', mode: null },
{ value: ModuleFieldType.TEXTAREA, label: 'Text Area', mode: null },
{ value: ModuleFieldType.EDITOR, label: 'TinyMCE Editor', mode: null }
];
const fieldFileOptions = [
{ value: ModuleFieldType.FILE, label: 'File Upload', mode: null },
{ value: ModuleFieldType.DROPZONE, label: 'Dropzone File Upload', mode: null }
];
const fieldSelectOptions = [
{ value: ModuleFieldType.SELECT, label: 'Select Dropdown', mode: null },
{ value: ModuleFieldType.COMBOBOX, label: 'Combobox(Select with search)', mode: null },
{ value: ModuleFieldType.CREATABLE_COMBOBOX, label: 'Combobox(Creatable)', mode: null },
{ value: ModuleFieldType.MULTISELECT, label: 'Multiselect', mode: null }
];
const fieldOtherOptions = [
{ value: ModuleFieldType.SWITCH, label: 'Switch', mode: null },
{ value: ModuleFieldType.RADIO, label: 'Radio group', mode: null },
{ value: ModuleFieldType.CHECKBOX, label: 'Checkbox group', mode: null },
{ value: ModuleFieldType.COLOR_PICKER, label: 'Color Picker', mode: null }
];
const fieldOptions = {
Select: fieldSelectOptions,
Text: fieldTextOptions,
Date: fieldDateOptions,
File: fieldFileOptions,
Other: fieldOtherOptions
};
export const FIELD_OPTIONS_LIST = Object.entries(fieldOptions).map(([label, options]) => ({
label,
options
}));
export const FIELDS_WITH_OPTIONS = [
ModuleFieldType.CHECKBOX,
ModuleFieldType.RADIO,
ModuleFieldType.SELECT,
ModuleFieldType.COMBOBOX,
ModuleFieldType.CREATABLE_COMBOBOX,
ModuleFieldType.MULTISELECT
];
export const FIELDS_WITH_GROUPS = [ModuleFieldType.COMBOBOX, ModuleFieldType.MULTISELECT];
export const FIELDS_WITH_PLACEHOLDER = [
ModuleFieldType.TEXT,
ModuleFieldType.EMAIL,
ModuleFieldType.NUMBER,
ModuleFieldType.PASSWORD,
ModuleFieldType.TEXTAREA,
ModuleFieldType.SELECT,
ModuleFieldType.EDITOR,
ModuleFieldType.NUMBER_COUNTER,
ModuleFieldType.COMBOBOX,
ModuleFieldType.CREATABLE_COMBOBOX,
ModuleFieldType.MULTISELECT
];
@@ -0,0 +1,494 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { useTableColumns } from '#devtools/data/queries/tableColumns';
import type { ModuleFieldConfig, ModuleFormContainer, ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import ModuleFormGrid from '#devtools/view/components/ModuleFormGrid';
import ToDocsButton from '#devtools/view/components/ToDocsButton';
import OffsetSeparator from '#devtools/view/ui/OffsetSeparator';
import { useModuleGeneratorFormContext } from '../../form';
import { checkCanSplit, createCellTemplate, createGrid } from '../../lib/grid';
import FormToolbar from './components/FormToolbar';
import ManageFormFieldDialog from './components/ManageFormFieldDialog';
export default function FormSection() {
const intl = useIntl();
const { watch, setValue } = useModuleGeneratorFormContext();
const table = watch('table');
const grid = watch('form.grid');
const cols = watch('form.cols');
const rows = watch('form.rows');
const dialogSize = watch('form.size');
const selectedCells = watch('form.selectedCells');
const { data: tableColumns = [] } = useTableColumns(table);
const tableColumnsOptions = tableColumns
.filter((col) => col.name !== 'id')
.map((col) => ({
value: col.name,
label: col.name + ': ' + col.type
}));
const [editingCell, setEditingCell] = React.useState<null | string>(null);
const addRow = React.useCallback(() => {
const updated: ModuleGridCell[] = [];
const _rows: Record<string, Array<ModuleGridCell>> = {};
grid.forEach((cell) => {
const currentRow = cell.row;
if (_rows[currentRow]) {
_rows[currentRow].push(cell);
} else {
_rows[currentRow] = [cell];
}
});
_rows[rows] = [];
for (let col = 0; col < cols; col++) {
_rows[rows].push(createCellTemplate(rows, col));
}
Object.values(_rows).forEach((row) => {
row.forEach((cell) => {
updated.push(cell);
});
});
setValue('form.grid', updated);
setValue('form.rows', rows + 1);
}, [cols, rows, grid, setValue]);
const addColumn = React.useCallback(() => {
const updated: ModuleGridCell[] = [];
const _rows: Record<string, Array<ModuleGridCell>> = {};
grid.forEach((cell) => {
const currentRow = cell.row;
if (_rows[currentRow]) {
_rows[currentRow].push(cell);
} else {
_rows[currentRow] = [cell];
}
});
// push new cell to the end of each row
Object.keys(_rows).forEach((rowKey) => {
_rows[rowKey].push(createCellTemplate(Number(rowKey), cols));
});
Object.values(_rows).forEach((row) => {
row.forEach((cell) => {
updated.push(cell);
});
});
setValue('form.grid', updated);
setValue('form.cols', cols + 1);
}, [cols, grid, setValue]);
const removeRow = React.useCallback(() => {
if (rows <= 1) return;
const rowIndex = rows - 1;
const rowCellsToRemove = grid.filter((cell) => cell.row === rowIndex);
const rowCellsIds = rowCellsToRemove.map((cell) => cell.id);
setValue(
'form.selectedCells',
selectedCells.filter((id) => !rowCellsIds.includes(id))
);
setValue(
'form.grid',
grid.filter((cell) => !rowCellsIds.includes(cell.id))
);
setValue('form.rows', rows - 1);
}, [rows, grid, selectedCells, setValue]);
const removeColumn = React.useCallback(() => {
if (cols <= 1) return;
const columnIndex = cols - 1;
const columnCellsToRemove = grid.filter((cell) => cell.col === columnIndex);
const columnCellsIds = columnCellsToRemove.map((cell) => cell.id);
setValue(
'form.selectedCells',
selectedCells.filter((id) => !columnCellsIds.includes(id))
);
const updated: ModuleGridCell[] = grid
.filter((cell) => !columnCellsIds.includes(cell.id))
.map((cell) => {
const unbindColumn = columnCellsToRemove.find((c) => c.mergeTo === cell.id);
if (unbindColumn) {
return {
...cell,
colSpan: cell.colSpan - 1
};
}
return cell;
});
setValue('form.grid', updated);
setValue('form.cols', cols - 1);
}, [cols, grid, selectedCells, setValue]);
const handleCellClick = React.useCallback(
(cellId: string) => {
setEditingCell(cellId);
},
[setEditingCell]
);
const handleCellSelect = React.useCallback(
(cellId: string, isSelected: boolean) => {
if (isSelected) {
setValue('form.selectedCells', [...selectedCells, cellId]);
} else {
setValue(
'form.selectedCells',
selectedCells.filter((id) => id !== cellId)
);
}
},
[selectedCells, setValue]
);
const mergeCells = React.useCallback(() => {
if (selectedCells.length < 2) return;
const cellsToMerge = grid.filter((cell) => selectedCells.includes(cell.id));
const filledCellsCount = cellsToMerge.reduce((acc, cur) => {
return acc + (cur.isEmpty ? 0 : 1);
}, 0);
if (filledCellsCount > 1) {
toast.error(intl.formatMessage({ id: 'DEVTOOLS.MODULES.ERROR.MERGE_FILLED_CELLS' }));
return;
}
// сheck if all selected cells are in the same row (horizontal merge only)
const rows = [...new Set(cellsToMerge.map((c) => c.row))];
if (rows.length > 1) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.ONLY_HORIZONTAL_MERGE_ALLOWED_MESSAGE'
})
);
return;
}
// check if all cells are consecutive
let nextIndex: number | null = null;
let isValid: boolean = true;
cellsToMerge.forEach((cell) => {
if (isValid === false) return;
if (nextIndex == null) {
nextIndex = cell.col + cell.colSpan;
} else {
// check is valid consecutive
if (cell.col === nextIndex) {
nextIndex = cell.col + cell.colSpan;
} else {
isValid = false;
}
}
});
if (!isValid) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.ONLY_CONSECUTIVE_MERGE_ALLOWED_MESSAGE'
})
);
return;
}
const sorterd = cellsToMerge.slice().sort((a, b) => a.col - b.col);
const mainCell = sorterd[0];
const notEmptyCell = sorterd.find((cell) => cell.isEmpty === false);
if (!mainCell) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SOMETHING_WENT_WRONG'
})
);
return;
}
const mainCellColSpan = sorterd.reduce((acc, cur) => acc + cur.colSpan, 0);
const mainCellId = mainCell.id;
const directMergedCells = grid
.filter(({ id }) => id !== mainCellId)
.filter(({ id }) => selectedCells.includes(id))
.map((cell) => cell.id);
const relatedToMergedCells = grid
.filter((cell) => directMergedCells.includes(cell.mergeTo as string))
.map((cell) => cell.id);
const newGrid = grid.map((cell) => {
if (mainCellId === cell.id) {
return {
...cell,
rowSpan: 1,
colSpan: mainCellColSpan,
mergeTo: null,
isEmpty: notEmptyCell == null ? true : false,
body: notEmptyCell ? notEmptyCell.body : null
};
}
if (directMergedCells.includes(cell.id)) {
return {
...cell,
rowSpan: 0,
colSpan: 0,
mergeTo: mainCellId,
isEmpty: true,
body: null
};
}
if (relatedToMergedCells.includes(cell.id)) {
return {
...cell,
rowSpan: 0,
colSpan: 0,
mergeTo: mainCellId,
isEmpty: true,
body: null
};
}
return cell;
});
setValue('form.grid', newGrid);
setValue('form.selectedCells', []);
}, [selectedCells, grid, intl, setValue]);
const splitCell = React.useCallback(
(id: string | null) => {
if (!id && selectedCells.length !== 1) return;
const cellId = id ? id : selectedCells[0];
const cellToSplit = grid.find((cell) => cell.id === cellId);
if (!cellToSplit || cellToSplit.colSpan <= 1) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SELECT_MERGED_CELL_TO_SPLIT'
})
);
return;
}
setValue(
'form.grid',
grid.map((cell) => {
if (cellId === cell.id) {
return { ...cell, colSpan: 1, rowSpan: 1 };
}
if (cell.mergeTo === cellId) {
return { ...cell, colSpan: 1, rowSpan: 1, mergeTo: null };
}
return cell;
})
);
setValue('form.selectedCells', []);
},
[selectedCells, grid, intl, setValue]
);
const resetGrid = React.useCallback(() => {
setValue('form.grid', createGrid(rows, cols));
setValue('form.selectedCells', []);
}, [rows, cols, setValue]);
const changeDialogSize = React.useCallback(
(value: ModuleFormContainer) => {
if (dialogSize === value || !value) return;
setValue('form.size', value);
},
[setValue, dialogSize]
);
const resetGridCell = React.useCallback(
(cellId: string) => {
const updated = grid.map((cell) => {
if (cell.id === cellId) {
return {
...cell,
body: null,
isEmpty: true
};
}
return cell;
});
setValue('form.grid', updated);
},
[grid, setValue]
);
const handleFieldSave = React.useCallback(
(body: ModuleFieldConfig) => {
if (!editingCell) return;
setValue(
'form.grid',
grid.map((cell) => {
if (editingCell === cell.id) {
return {
...cell,
body: body,
fieldType: body.type,
isEmpty: false
};
}
return cell;
})
);
setEditingCell(null);
},
[editingCell, grid, setValue]
);
const handleSwap = () => {
if (selectedCells.length !== 2) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SELECT_TWO_CELLS_TO_SWAP'
})
);
return;
}
const cell1 = grid.find((cell) => cell.id === selectedCells[0]);
const cell2 = grid.find((cell) => cell.id === selectedCells[1]);
if (!cell1 || !cell2) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.SELECT_TWO_CELLS_TO_SWAP'
})
);
return;
}
if (cell1.isEmpty && cell2.isEmpty) {
toast.error(
intl.formatMessage({
id: 'DEVTOOLS.MODULES.ERROR.CANT_SWAP_EMPTY_CELLS'
})
);
return;
}
const updated = grid.map((cell) => {
if (cell.id === cell1.id) {
return {
...cell,
body: cell2.body,
isEmpty: cell2.body == null ? true : false
};
}
if (cell.id === cell2.id) {
return {
...cell,
body: cell1.body,
isEmpty: cell1.body == null ? true : false
};
}
return cell;
});
setValue('form.grid', updated);
setValue('form.selectedCells', []);
};
const existingFields = grid.map((i) => i.body?.dbColumn).filter((i) => typeof i === 'string');
return (
<React.Fragment>
<OffsetSeparator action={<ToDocsButton to="/devtools/module-generator/docs?section=form" />}>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.FORM' })}
</OffsetSeparator>
<div className="space-y-6">
<FormToolbar
dialogSize={dialogSize}
rows={rows}
cols={cols}
grid={grid}
onAddRow={addRow}
onAddColumn={addColumn}
onRemoveRow={removeRow}
onRemoveColumn={removeColumn}
onMergeCells={mergeCells}
onSplitCell={() => splitCell(null)}
onReset={resetGrid}
onSwap={handleSwap}
onDialogSizeChange={changeDialogSize}
canMerge={selectedCells.length >= 2}
canSplit={checkCanSplit(grid, selectedCells)}
canAddRow={true}
canRemoveRow={rows > 1}
canAddColumn={cols < 12}
canRemoveColumn={cols > 1}
canSwap={selectedCells.length === 2}
selectedCount={selectedCells.length}
/>
<ModuleFormGrid
data={grid}
rows={rows}
cols={cols}
selectedCells={selectedCells}
onCellClick={handleCellClick}
onCellSelect={handleCellSelect}
onCellReset={resetGridCell}
onCellSplit={splitCell}
/>
<ManageFormFieldDialog
tableColumns={tableColumnsOptions}
disabledFields={existingFields}
isOpen={editingCell != null}
onClose={() => setEditingCell(null)}
onSave={handleFieldSave}
initialData={
editingCell ? (grid.find((c) => c.id === editingCell)?.body ?? undefined) : undefined
}
/>
</div>
</React.Fragment>
);
}
@@ -0,0 +1,60 @@
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
export const IGNORE_COLUMNS = ['createdAt', 'created_at', 'updatedAt', 'updated_at'];
export const GENERATE_OPTIONS = [
{
label: 'Model',
value: ModuleAsset.MODEL,
scope: 'backend'
},
{
label: 'Controller',
value: ModuleAsset.CONTROLLER,
description: 'description',
scope: 'backend'
},
{
label: 'Routes',
value: ModuleAsset.ROUTES,
scope: 'backend'
},
{
label: 'Validator',
value: ModuleAsset.VALIDATOR,
scope: 'backend'
},
{
label: 'Policy',
value: ModuleAsset.POLICY,
description: 'description',
scope: 'backend'
},
{
label: 'Api',
value: ModuleAsset.API,
scope: 'backend'
},
{
label: 'Form',
value: ModuleAsset.FORM,
description: (
<ul>
<li className="list-inside list-disc">Create/Update modal</li>
<li className="list-inside list-disc">Edit button in dataTable</li>
</ul>
),
scope: 'frontend'
},
{
label: 'DataTable',
value: ModuleAsset.DATATABLE,
description: (
<p>
UI component that displays tabular data (rows and columns). Includes features like{' '}
<b>pagination</b>, <b>sorting</b>, <b>filtering</b>, and <b>actions (edit/delete)</b>
</p>
),
scope: 'frontend'
}
];
@@ -0,0 +1,188 @@
import { LoaderCircleIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useTableColumns } from '#devtools/data/queries/tableColumns';
import { useGetTables } from '#devtools/data/queries/tables';
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
import snakeToCamel from '#devtools/shared/string/snakeToCamel';
import ToDocsButton from '#devtools/view/components/ToDocsButton';
import OffsetSeparator from '#devtools/view/ui/OffsetSeparator';
import { Badge } from '#shared/ui/Badge';
import { Checkbox } from '#shared/ui/Checkbox';
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '#shared/ui/form';
import { Input } from '#shared/ui/Input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#shared/ui/Select';
import { datatableInitialValues, formInitialValues, initialGenerateValue, useModuleGeneratorFormContext } from '../../form';
import { GENERATE_OPTIONS } from './config';
import createFormColumns from './lib/createFormColumns';
const MetadataSection: React.FC = () => {
const intl = useIntl();
const { control, setValue, watch, setFocus } = useModuleGeneratorFormContext();
const table = watch('table');
const { data: tables = [], isLoading } = useGetTables();
const { isFetching, data: tableColumns = [] } = useTableColumns(table);
const onChangeTable = React.useCallback(
(value: string) => {
setValue('table', value);
setValue('form', formInitialValues);
setValue('datatable', datatableInitialValues);
setValue('generate', initialGenerateValue);
setValue('name', snakeToCamel(value), { shouldValidate: true });
setTimeout(() => setFocus('name'), 100);
},
[setValue, setFocus]
);
return (
<React.Fragment>
<OffsetSeparator
action={<ToDocsButton to="/devtools/module-generator/docs?section=metadata" />}
>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MODULE_METADATA' })}
</OffsetSeparator>
<div className="flex flex-col gap-6 md:flex-row">
<FormField
control={control}
name="table"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel className="flex gap-2">
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.TABLE_NAME' })}
{(isLoading || isFetching) && <LoaderCircleIcon className="size-3 animate-spin" />}
</FormLabel>
<Select value={field.value} onValueChange={onChangeTable}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.SELECT_TABLE'
})}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{tables.map((tableName, index) => (
<SelectItem key={index} value={tableName}>
<Badge variant="info" appearance="outline" size="sm">
{tableName}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.MODULE_NAME' })}</FormLabel>
<FormControl>
<Input
{...field}
id="name"
type="string"
className="font-medium tracking-widest placeholder:font-normal placeholder:tracking-normal"
placeholder={intl.formatMessage({
id: 'DEVTOOLS.MODULES.ENTER_MODULE_NAME'
})}
/>
</FormControl>
<FormMessage />
<FormDescription>
{intl.formatMessage(
{ id: 'DEVTOOLS.MODULES.MANUAL.MANUAL_FOR_MODULE_NAME_DESC' },
{
children: <i key="MANUAL_FOR_MODULE_NAME_DESC">camelCase</i>
}
)}
</FormDescription>
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="generate"
render={() => (
<FormItem>
<FormLabel className={!table ? 'opacity-50' : ''}>
{intl.formatMessage({
id: 'DEVTOOLS.MODULES.INCLUDE_IN_MODULE'
})}
:
</FormLabel>
<div className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${!table && 'opacity-50'}`}>
{GENERATE_OPTIONS.map((item, index) => (
<FormField
key={index}
control={control}
name="generate"
disabled={!table}
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2">
<FormControl>
<Checkbox
disabled={field.disabled}
checked={field.value?.includes(item.value)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, item.value]);
if (item.value === ModuleAsset.DATATABLE) {
setValue('datatable.columns', createFormColumns(tableColumns));
}
} else {
field.onChange(field.value?.filter((value) => value !== item.value));
if (item.value === ModuleAsset.FORM) {
setValue('form', formInitialValues);
}
if (item.value === ModuleAsset.DATATABLE) {
setValue('datatable', datatableInitialValues);
}
}
}}
/>
</FormControl>
<div>
<FormLabel className="text-sm font-normal capitalize">
{item.label}
<Badge
size="xs"
variant={item.scope === 'backend' ? 'primary' : 'success'}
appearance="outline"
className="ml-2"
>
{item.scope}
</Badge>
</FormLabel>
<FormDescription>{item.description}</FormDescription>
</div>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
</React.Fragment>
);
};
export default MetadataSection;
@@ -0,0 +1,42 @@
import { capitalizeEveryWord } from '@crux-kit/utils';
import type { TableColumn } from '#devtools/data/DevtoolsDTO';
import type { ModuleTableColumn } from '#devtools/entities/ModuleGeneratorTypes';
import { IGNORE_COLUMNS } from '../config';
function nameToLabel(initial: string): string {
if (!initial) return '';
return capitalizeEveryWord(initial.replace(/_/g, ' '));
}
function hideColumnFirst(columnName: string): boolean {
return IGNORE_COLUMNS.includes(columnName);
}
export default function createFormColumns(data: TableColumn[]): ModuleTableColumn[] {
const prepared = data.map((column) => ({
value: column.name,
label: nameToLabel(column.name),
hidden: hideColumnFirst(column.name),
isActionColumn: false,
innerProps: {
badge: false,
sort: false,
copy: false
}
}));
prepared.push({
hidden: false,
label: 'Actions',
value: 'default_action_column',
isActionColumn: true,
innerProps: {
badge: false,
sort: false,
copy: false
}
});
return prepared;
}
@@ -0,0 +1,69 @@
import { LucideIcon } from 'lucide-react';
import React from 'react';
import { cn } from '@crux-kit/utils';
import { NavigationMenuContent, NavigationMenuItem, NavigationMenuTrigger } from '#shared/ui/NavigationMenu';
import { MegaMenuLink } from './MegaMenuLink';
const linkClass = cn(
'text-sm text-secondary-foreground font-medium rounded-none px-0 border-b border-transparent',
'data-[active=true]:text-mono data-[active=true]:bg-transparent data-[active=true]:border-mono',
'data-[state=open]:text-mono data-[state=open]:bg-transparent',
'hover:text-primary hover:bg-transparent',
'focus:text-primary focus:bg-transparent'
);
export const MegaMenuItem: React.FC<
React.PropsWithChildren<{
isActive: boolean;
title: string;
columns: Array<{
heading: string;
items: Array<{
to: string;
icon: LucideIcon;
title: string;
}>;
}>;
}>
> = ({ isActive, columns, title, children }) => {
return (
<NavigationMenuItem>
<NavigationMenuTrigger className={linkClass} data-active={isActive}>
{title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<div className={cn('w-[calc(100vw-2rem)] max-w-2xl gap-0')}>
<div className="pt-4 pb-2 lg:p-7.5">
<div
className={cn(
'grid gap-5 lg:gap-10',
columns.length === 1 && 'lg:grid-cols-1',
columns.length === 2 && 'lg:grid-cols-2',
columns.length >= 3 && 'lg:grid-cols-3'
)}
>
{columns.map((column, index) => (
<div className="flex flex-col" key={index}>
<h3 className="text-foreground mb-2 ps-2.5 text-sm leading-none font-semibold lg:mb-4">
{column.heading}
</h3>
<div className="grid gap-0">
<div className="space-y-0.5">
{column.items.map((item, key) => (
<MegaMenuLink key={key} to={item.to}>
<item.icon data-slot="accordion-menu-icon" className="size-4" />
{item.title}
</MegaMenuLink>
))}
</div>
</div>
</div>
))}
</div>
</div>
{children}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
);
};
@@ -0,0 +1,23 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button } from '#shared/ui/Button';
export const MegaMenuItemFooter: React.FC<{
title: string;
desc: React.ReactNode;
to: string;
bottonTitle: string;
}> = ({ bottonTitle, desc, title, to }) => {
return (
<div className="border-border lg:border-t-border bg-muted/50 flex flex-wrap items-center gap-2.5 rounded-xl border px-4 py-4 lg:justify-between lg:rounded-t-none lg:border-0 lg:border-t lg:px-7.5 lg:py-5">
<div className="flex flex-col gap-1.5">
<div className="text-mono text-base leading-none font-semibold">{title}</div>
<div className="fomt-medium text-secondary-foreground text-sm">{desc}</div>
</div>
<Button variant="mono" asChild>
<Link to={to}>{bottonTitle}</Link>
</Button>
</div>
);
};
@@ -0,0 +1,34 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { NavigationMenuLink } from '#shared/ui/NavigationMenu';
export const MegaMenuLink: React.FC<
React.PropsWithChildren<{
to: string;
}>
> = ({ to, children }) => {
const { pathname, search } = useLocation();
const [targetPath, targetSearch = ''] = to.split('?');
const targetParams = new URLSearchParams(targetSearch);
const currentParams = new URLSearchParams(search);
const isActive =
pathname === targetPath &&
Array.from(targetParams.entries()).every(([key, value]) => currentParams.get(key) === value);
return (
<NavigationMenuLink asChild>
<Link
data-active={isActive}
to={to}
className={cn(
'hover:bg-accent/50 flex flex-row items-center gap-2.5 rounded-md px-2.5 py-2 text-sm',
'[&_svg]:text-muted-foreground hover:[&_svg]:text-primary [&[data-active=true]_svg]:text-primary'
)}
>
{children}
</Link>
</NavigationMenuLink>
);
};
@@ -0,0 +1,151 @@
import { Activity, BetweenHorizonalStart, BookCopy, GitPullRequestArrow, PackagePlus, ScanEye, SquareMousePointer, Table, TextCursorInput } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { Outlet, useLocation } from 'react-router';
import DevtoolsLogo from '#devtools/view/ui/DevtoolsLogo';
import { NavigationMenu, NavigationMenuList } from '#shared/ui/NavigationMenu';
import { Container } from '#shared/widgets/common/Container';
import { MegaMenuItem } from './MegaMenuItem';
import { MegaMenuItemFooter } from './MegaMenuItemFooter';
export default function DevtoolsLayout() {
const intl = useIntl();
const { pathname } = useLocation();
return (
<React.Fragment>
<Container>
<header className="flex w-full items-center gap-1 pb-5">
<div className="flex w-full items-center gap-2">
<DevtoolsLogo />
<h1 className="text-mono text-lg font-medium">
{intl.formatMessage({ id: 'DEVTOOLS.TITLE' })}
</h1>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex grow items-stretch gap-1.5 lg:gap-10">
<NavigationMenu viewportClassName="right-0 left-auto max-w-[calc(100vw-2rem)]">
<NavigationMenuList className="gap-7.5">
<MegaMenuItem
isActive={pathname.startsWith('/devtools/module-generator')}
title={intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE'
})}
columns={[
{
heading: intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE'
}),
items: [
{
icon: PackagePlus,
title: intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_GENERATOR.TITLE'
}),
to: '/devtools/module-generator'
}
]
},
{
heading: intl.formatMessage({ id: 'DEVTOOLS.DOCUMENTATION' }),
items: [
{
icon: ScanEye,
title: intl.formatMessage({ id: 'DEVTOOLS.MODULE_OVERVIEW' }),
to: '/devtools/module-generator/docs?section=overview'
},
{
icon: Activity,
title: intl.formatMessage({
id: 'DEVTOOLS.MODULES.MODULE_METADATA'
}),
to: '/devtools/module-generator/docs?section=metadata'
},
{
icon: BookCopy,
title: intl.formatMessage({ id: 'DEVTOOLS.MODULES.MODULE_FORM' }),
to: '/devtools/module-generator/docs?section=form'
},
{
icon: Table,
title: intl.formatMessage({ id: 'DEVTOOLS.MODULES.DATATABLE' }),
to: '/devtools/module-generator/docs?section=datatable'
},
{
icon: BetweenHorizonalStart,
title: intl.formatMessage({ id: 'DEVTOOLS.TUTORIAL' }),
to: '/devtools/module-generator/docs?section=tutorial'
}
]
},
{
heading: intl.formatMessage({ id: 'DEVTOOLS.SETTINGS' }),
items: [
{
icon: GitPullRequestArrow,
title: intl.formatMessage({ id: 'DEVTOOLS.VERSION_MANAGER' }),
to: '/devtools/module-generator/version'
}
]
}
]}
>
<MegaMenuItemFooter
title={intl.formatMessage({
id: 'DEVTOOLS.DONT_KNOW_WHERE_TO_START'
})}
desc={intl.formatMessage({
id: 'DEVTOOLS.START_WITH_YOURSELF'
})}
to="/devtools/module-generator/docs?section=overview"
bottonTitle={intl.formatMessage({
id: 'DEVTOOLS.MODULE_GENERATOR_DOCS'
})}
/>
</MegaMenuItem>
<MegaMenuItem
isActive={pathname.startsWith('/devtools/sandbox')}
title={intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.TITLE' })}
columns={[
{
heading: intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.TITLE' }),
items: [
{
icon: SquareMousePointer,
title: intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.UI' }),
to: '/devtools/sandbox/ui'
},
{
icon: TextCursorInput,
title: intl.formatMessage({
id: 'DEVTOOLS.SANDBOX.FORM_CONTROLS'
}),
to: '/devtools/sandbox/forms'
}
]
}
]}
>
<MegaMenuItemFooter
title={intl.formatMessage({
id: 'DEVTOOLS.SANDBOX.PLAYGROUND_TITLE'
})}
desc={intl.formatMessage({
id: 'DEVTOOLS.SANDBOX.PLAYGROUND_DESCRIPTION'
})}
to="/devtools/sandbox/ui"
bottonTitle={intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.OPEN' })}
/>
</MegaMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
</header>
<Outlet />
</Container>
</React.Fragment>
);
}
@@ -0,0 +1,22 @@
import { useIntl } from 'react-intl';
import { Outlet } from 'react-router-dom';
export default function SandboxLayout() {
const intl = useIntl();
return (
<div className="space-y-6">
<div className="border-border flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
{intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.TITLE' })}
</h2>
<p className="text-muted-foreground max-w-2xl text-sm">
{intl.formatMessage({ id: 'DEVTOOLS.SANDBOX.DESCRIPTION' })}
</p>
</div>
</div>
<Outlet />
</div>
);
}
@@ -0,0 +1,14 @@
import React from 'react';
import { Separator } from '#shared/ui/Separator';
const CentralSeparator: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<div className="mb-2 flex w-full items-center gap-4">
<Separator className="w-auto grow" />
<h3 className="text-md shrink-0">{children}</h3>
<Separator className="w-auto grow" />
</div>
);
};
export default CentralSeparator;
@@ -0,0 +1,28 @@
import React from 'react';
import { cn } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
const DevtoolsLogo: React.FC<React.HTMLAttributes<HTMLImageElement>> = ({
children,
className,
...props
}) => {
return (
<React.Fragment>
<img
src={toAbsoluteUrl('/media/app/devtools-dark.svg')}
className={cn('size-8.5 dark:hidden', className)}
alt="File Manager"
{...props}
/>
<img
src={toAbsoluteUrl('/media/app/devtools-light.svg')}
className={cn('hidden size-8.5 dark:inline-block', className)}
alt="File Manager"
{...props}
/>
</React.Fragment>
);
};
export default DevtoolsLogo;
@@ -0,0 +1,28 @@
import React from 'react';
import { Separator } from '#shared/ui/Separator';
const OffsetSeparator: React.FC<React.PropsWithChildren & { action?: React.ReactNode }> = ({
children,
action
}) => {
return (
<div className="flex w-full items-center gap-4">
<Separator className="w-10 shrink-0" />
<h3 className="text-md shrink-0">{children}</h3>
<Separator className="w-auto grow" />
{action}
</div>
);
};
export default OffsetSeparator;
export const CentralSeparator: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<div className="mb-2 flex w-full items-center gap-4">
<Separator className="w-auto grow" />
<h3 className="text-md shrink-0">{children}</h3>
<Separator className="w-auto grow" />
</div>
);
};
@@ -0,0 +1,100 @@
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
import type { ModuleFieldConfig } from '#devtools/entities/ModuleGeneratorTypes';
import { toComboboxGroups, toMultiSelectOptions } from '#devtools/shared/options/fieldOptionGroups';
import { InputCalendar, InputCheckboxGroup, InputColorPicker, InputCombobox, InputCreatableCombobox, InputDate, InputDatePicker, InputDatePickerRange, InputDropzone, InputFile, InputMultiSelect, InputNumberCounter, InputPhone, InputRadioGroup, InputSelect, InputSwitch, InputText, InputTextarea, InputTime, InputTiptapEditor } from '#shared/forms/composed';
import { defaultOptions } from './options';
const FieldItem: React.FC<{
body: ModuleFieldConfig | null;
}> = ({ body }) => {
if (!body) {
return null;
}
const { label, placeholder, required, options, type, dbColumn: name } = body;
const opts = options && options.length ? options : defaultOptions;
// prettier-ignore
switch (type) {
case ModuleFieldType.CHECKBOX:
return <InputCheckboxGroup name={name} label={label} options={opts} required={required} />;
case ModuleFieldType.RADIO:
return <InputRadioGroup name={name} label={label} options={opts} required={required} />;
case ModuleFieldType.TEXT:
return <InputText name={name} type="text" label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.EMAIL:
return <InputText name={name} type="email" label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.PHONE:
return <InputPhone name={name} label={label} required={required} />;
case ModuleFieldType.NUMBER:
return <InputText name={name} type="number" label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.PASSWORD:
return <InputText name={name} type="password" label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.TEXTAREA:
return <InputTextarea name={name} label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.FILE:
return <InputFile name={name} label={label} required={required} />;
case ModuleFieldType.DROPZONE:
return <InputDropzone name={name} label={label} required={required} />;
case ModuleFieldType.SELECT:
return <InputSelect name={name} label={label} placeholder={placeholder} options={opts} required={required} />;
case ModuleFieldType.SWITCH:
return <InputSwitch name={name} label={label} required={required} wrapperClassName="flex-col items-start" />;
case ModuleFieldType.DATE_PICKER_RANGE:
return <InputDatePickerRange name={name} label={label} required={required} />;
case ModuleFieldType.DATE_PICKER_SINGLE:
return <InputDatePicker name={name} label={label} required={required} />;
case ModuleFieldType.CALENDAR:
return <InputCalendar name={name} label={label} required={required} />;
case ModuleFieldType.DATE_INPUT:
return <InputDate name={name} label={label} required={required} />;
case ModuleFieldType.TIME_INPUT_ISO:
return <InputTime format="ISO" name={name} label={label} required={required} />;
case ModuleFieldType.TIME_INPUT_HMS:
return <InputTime format="HMS" name={name} label={label} required={required} />;
case ModuleFieldType.EDITOR:
/** TinyMCE editor
* return (
* <InputMCEEditor
* name={name}
* label={label}
* placeholder={placeholder}
* required={required}
* toolbarOptions={{ mergeTags: tinymceMergeTags }}
* toolbarLayout={tinymceLayout}
* />;
* )
*/
return <InputTiptapEditor name={name} label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.COLOR_PICKER:
return <InputColorPicker name={name} label={label} required={required} />;
case ModuleFieldType.NUMBER_COUNTER:
return <InputNumberCounter name={name} label={label} placeholder={placeholder} required={required} />;
case ModuleFieldType.COMBOBOX:
return <InputCombobox name={name} label={label} placeholder={placeholder} groups={toComboboxGroups(opts)} required={required} />;
case ModuleFieldType.CREATABLE_COMBOBOX:
return <InputCreatableCombobox name={name} label={label} placeholder={placeholder} options={opts} required={required} />;
case ModuleFieldType.MULTISELECT:
return <InputMultiSelect name={name} label={label} placeholder={placeholder} options={toMultiSelectOptions(opts)} required={required} />;
default:
return null;
}
};
export const Field: React.FC<{
body: ModuleFieldConfig | null;
colSpan: number;
rowSpan: number;
}> = ({ body, colSpan, rowSpan }) => {
const fieldStyle = {
gridColumn: `span ${colSpan}`,
gridRow: `span ${rowSpan}`
};
return (
<div style={fieldStyle}>
<FieldItem body={body} />
</div>
);
};
@@ -0,0 +1,85 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { ModuleFormContainer, ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '#shared/ui/Sheet';
import { FormView } from './FormView';
import getFormPreviewContainer from './getFormPreviewContainer';
export const FormPreviewTrigger: React.FC<
React.PropsWithChildren<{
grid: ModuleGridCell[];
cols: number;
rows: number;
size: ModuleFormContainer;
}>
> = ({ children, cols, grid, rows, size }) => {
const intl = useIntl();
const container = getFormPreviewContainer(size);
const handleSubmit = () => {
toast.success(intl.formatMessage({ id: 'DEVTOOLS.SUCCESS' }));
};
if (container.variant === 'sheet') {
return (
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent side={container.size} className="max-h-dvh">
<SheetHeader>
<SheetTitle>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.FORM_PREVIEW' })}</SheetTitle>
<SheetDescription>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.FORM_PREVIEW_DESCRIPTION' })}
</SheetDescription>
</SheetHeader>
<SheetBody className="overflow-y-auto">
<FormView gridData={grid} cols={cols} rows={rows} />
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button type="submit" onClick={handleSubmit}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.SUBMIT' })}
</Button>
</SheetClose>
<SheetClose asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent variant={container.size}>
<DialogHeader>
<DialogTitle>{intl.formatMessage({ id: 'DEVTOOLS.MODULES.FORM_PREVIEW' })}</DialogTitle>
<DialogDescription>
{intl.formatMessage({ id: 'DEVTOOLS.MODULES.FORM_PREVIEW_DESCRIPTION' })}
</DialogDescription>
</DialogHeader>
<DialogBody>
<FormView gridData={grid} cols={cols} rows={rows} />
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="submit" onClick={handleSubmit}>
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.SUBMIT' })}
</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'DEVTOOLS.ACTION.CANCEL' })}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,35 @@
import { useForm } from 'react-hook-form';
import type { ModuleGridCell } from '#devtools/entities/ModuleGeneratorTypes';
import { FormElement } from '#shared/forms/composed';
import { Form as FormProvider } from '#shared/ui/form';
import { Field } from './Field';
export const FormView: React.FC<{
gridData: ModuleGridCell[];
rows: number;
cols: number;
}> = ({ gridData, rows, cols }) => {
const gridStyle = {
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, auto)`,
gap: '16px',
width: '100%'
};
const visibleCells = gridData.filter((cell) => cell.colSpan > 0);
const form = useForm();
return (
<FormProvider {...form}>
<FormElement onSubmit={form.handleSubmit(() => {})}>
<div style={gridStyle}>
{visibleCells.map((cell) => (
<Field key={cell.id} colSpan={cell.colSpan} rowSpan={cell.rowSpan} body={cell.body} />
))}
</div>
</FormElement>
</FormProvider>
);
};
@@ -0,0 +1,38 @@
import type { ModuleFormContainer } from '#devtools/entities/ModuleGeneratorTypes';
import type { DialogVariantName } from '#shared/ui/Dialog';
import type { SheetContent } from '#shared/ui/Sheet';
type FormPreviewContainer =
| {
variant: 'dialog';
size: DialogVariantName;
}
| {
variant: 'sheet';
size: NonNullable<React.ComponentProps<typeof SheetContent>['side']>;
};
export default function getFormPreviewContainer(
container: ModuleFormContainer
): FormPreviewContainer {
switch (container) {
case 'dialog-default':
return { variant: 'dialog', size: 'default' };
case 'dialog-md':
return { variant: 'dialog', size: 'md' };
case 'dialog-fullscreen':
return { variant: 'dialog', size: 'fullscreen' };
case 'sheet-top':
return { variant: 'sheet', size: 'top' };
case 'sheet-right':
return { variant: 'sheet', size: 'right' };
case 'sheet-bottom':
return { variant: 'sheet', size: 'bottom' };
case 'sheet-left':
return { variant: 'sheet', size: 'left' };
default: {
const exhaustiveCheck: never = container;
throw new Error(`Unsupported form container: ${exhaustiveCheck}`);
}
}
}

Some files were not shown because too many files have changed in this diff Show More