Add initial project files
This commit is contained in:
Executable
+53
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
Executable
+10
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
Executable
+71
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
+122
@@ -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>
|
||||
);
|
||||
}
|
||||
+100
@@ -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;
|
||||
+30
@@ -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;
|
||||
+18
@@ -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/<name>/config/meta.ts</code> with backend
|
||||
permissions in{' '}
|
||||
<code>apps/backend/modules/<ModuleName>/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);
|
||||
+80
@@ -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;
|
||||
+30
@@ -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;
|
||||
+55
@@ -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;
|
||||
+22
@@ -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;
|
||||
+184
@@ -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;
|
||||
+155
@@ -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;
|
||||
+22
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
});
|
||||
}
|
||||
+15
@@ -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;
|
||||
});
|
||||
}
|
||||
+10
@@ -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;
|
||||
});
|
||||
}
|
||||
+191
@@ -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;
|
||||
+229
@@ -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;
|
||||
+62
@@ -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
|
||||
];
|
||||
+494
@@ -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>
|
||||
);
|
||||
}
|
||||
+60
@@ -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'
|
||||
}
|
||||
];
|
||||
+188
@@ -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;
|
||||
+42
@@ -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
Reference in New Issue
Block a user