diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fc376..038346f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- Refactored admin module-generator source builders to compose imports and source sections through + shared source-file helpers while preserving the existing generation payload. +- Renamed module-generator builder classes and files from template-builder terminology to + source-builder terminology. +- Updated module-generator frontend documentation to reference the new source-builder names. + ## [1.0.1] - 2026-06-30 ### Fixed diff --git a/apps/admin/src/devtools/data/builders/DataTableSourceBuilder.ts b/apps/admin/src/devtools/data/builders/DataTableSourceBuilder.ts new file mode 100644 index 0000000..86441d5 --- /dev/null +++ b/apps/admin/src/devtools/data/builders/DataTableSourceBuilder.ts @@ -0,0 +1,381 @@ +import { ModuleAsset } from '#devtools/entities/ModuleAsset'; +import { ModuleGeneratorConfig, ModuleTableColumn, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes'; +import { indentSource, SourceFile, source } from './shared/SourceFile'; + +class DataTableSourceBuilder { + build(data: ModuleGeneratorConfig) { + const file = new SourceFile(); + + this.addImports(file, data); + file.addSection(this.buildColumnFilterPropsInterface()); + file.addSection(this.buildDataTableComponent(data)); + file.addSection(this.buildToolbarComponent(data)); + file.addSection(this.buildColumnInputFilterComponent()); + + return file.toString(); + } + + private addImports(file: SourceFile, data: ModuleGeneratorConfig) { + const shouldAddEditForm = this.shouldAddEditForm(data.generate); + const icons = ['LoaderCircleIcon', 'SearchIcon', 'Settings2']; + const metaImports = ['MODULE_PERMISSION_DELETE']; + + if (shouldAddEditForm) { + icons.push('SquarePen'); + metaImports.unshift('MODULE_NAME_LABEL', 'MODULE_PERMISSION_CREATE'); + metaImports.push('MODULE_PERMISSION_UPDATE'); + } + + file.addImports([ + "import React from 'react';", + ` + import { + Column, + ColumnDef, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + SortingState, + useReactTable, + } from '@tanstack/react-table'; + `, + `import { ${icons.join(', ')} } from 'lucide-react';`, + "import { useIntl } from 'react-intl';", + "import { Badge } from '#shared/ui/Badge';", + "import { CopyButton } from '#shared/ui/CopyButton';", + "import { Button } from '#shared/ui/Button';", + ` + import { + Card, + CardFooter, + CardHeader, + CardTable, + CardTitle, + CardToolbar, + } from '#shared/ui/Card'; + `, + "import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid';", + "import { Input, InputWrapper } from '#shared/ui/Input';", + "import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';", + "import { Skeleton } from '#shared/ui/Skeleton';", + "import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';", + "import { useDebounceSearch } from '@crux-kit/hooks';", + "import { PermissionGate } from '#core/Permission';", + "import { useGetList } from '../../api/list';", + `import { ${metaImports.join(', ')} } from '../../config/meta';`, + "import { Entity } from '../../entities/Entity';", + "import { DeleteTrigger } from './DeleteTrigger';" + ]); + + if (shouldAddEditForm) { + file.addImport("import { ManageTrigger } from './ManageTrigger';"); + } + } + + private buildColumnFilterPropsInterface() { + return ` + interface IColumnFilterProps { + column: Column + }; + `; + } + + private buildDataTableComponent(data: ModuleGeneratorConfig) { + return ` + export const DataTable = () => { + const intl = useIntl(); + + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + + const [search, setSearch] = React.useState(''); + + const { handleChange } = useDebounceSearch({ + minQueryNumber: 3, + delay: 400, + initialQuery: search, + onTimeOut: setSearch, + }); + + const [sorting, setSorting] = React.useState([]); + + const { list, total, isFetching, isLoading, pagesCount } = useGetList( + mapTableStateToListQueryParams(pagination, sorting, search) + ); + + const columns = React.useMemo[]>( + () => [ +${indentSource(this.createColumnsSource(data), 12)} + ], + [intl] + ); + + const table = useReactTable({ + columns, + autoResetPageIndex: false, + enableMultiSort: false, + data: list, + manualPagination: true, + pageCount: pagesCount, + getRowId: (row) => String(row.id), + state: { + pagination, + sorting, + }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + + + + {search.length > 0 && isFetching ? ( + + ) : ( + + )} + handleChange(e.target.value)} + /> + + + + + + + + + + + + + + + + ) + }; + `; + } + + private buildToolbarComponent(data: ModuleGeneratorConfig) { + return ` + const Toolbar = () => { + const { table } = useDataGrid(); + const intl = useIntl(); + + return ( + +
+${this.shouldAddEditForm(data.generate) ? indentSource(this.buildCreateButton(), 14) : ''} + + + {intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })} + + } + /> +
+
+ ); + }; + `; + } + + private buildColumnInputFilterComponent() { + return ` + const ColumnInputFilter = ({ column }: IColumnFilterProps) => { + const intl = useIntl(); + + return ( + column.setFilterValue(event.target.value)} + className="h-9 w-full max-w-40" + /> + ); + }; + `; + } + + private buildCreateButton() { + return ` + + + + + + `; + } + + private buildEditButton() { + return ` + + + + + + `; + } + + private shouldAddEditForm(assets: ModuleAsset[]): boolean { + return assets.includes(ModuleAsset.FORM); + } + + private createColumnsSource(ctx: ModuleGeneratorConfig) { + const data: ModuleTableConfig = ctx.datatable; + const columns: string[] = []; + + data.columns + .filter((column) => column.hidden === false) + .forEach((column) => { + const name = column.value; + + if (column.isActionColumn) { + columns.push(this.createActionColumn(column, ctx)); + return; + } + if (name === 'id' || name === 'uuid') { + columns.push(this.createIdColumn(column)); + return; + } + + columns.push(this.createBaseColumn(column)); + }); + + return columns.map(source).join(',\n'); + } + + private createBaseColumn(data: ModuleTableColumn) { + const enableSorting = data.innerProps.sort ? 'true' : 'false'; + + return ` + { + id: '${data.value}', + accessorFn: (row) => row.${data.value}, + header: ({ column }) => ( + } + column={column} + /> + ), + cell: ${source(this.createCellContent(data))}, + enableSorting: ${enableSorting}, + meta: { + cellClassName: '', + skeleton: , + }, + } + `; + } + + private createIdColumn(data: ModuleTableColumn) { + return ` + { + id: '${data.value}', + accessorFn: (row) => row.id, + header: ({ column }) => , + cell: ${source(this.createCellContent(data))}, + enableSorting: true, + size: 80, + meta: { + cellClassName: '', + skeleton: , + }, + } + `; + } + + private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) { + const editButtonSource = this.shouldAddEditForm(ctx.generate) ? this.buildEditButton() : ''; + + return ` + { + id: '${data.value}', + header: '${data.label}', + cell: (info) => { + const data = info.row.original; + const id = data.id; + + return ( +
+${editButtonSource ? indentSource(editButtonSource, 14) : ''} + + + +
+ ); + }, + enableSorting: false, + size: 96, + meta: { + headerClassName: '', + skeleton: , + }, + } + `; + } + + private createCellContent(data: ModuleTableColumn) { + if (data.innerProps.copy) { + return ` + (info) => { + const val = String(info.getValue()); + return ( +
+ {val} + +
+ ); + } + `; + } + + if (data.innerProps.badge) { + return `(info) => {String(info.getValue())}`; + } + + return '(info) => info.getValue()'; + } +} + +export default new DataTableSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/DataTableTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/DataTableTemplateBuilder.ts deleted file mode 100644 index a47aafd..0000000 --- a/apps/admin/src/devtools/data/builders/DataTableTemplateBuilder.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { ModuleAsset } from '#devtools/entities/ModuleAsset'; -import { ModuleGeneratorConfig, ModuleTableColumn, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes'; - -class DataTableTemplateBuilder { - build(data: ModuleGeneratorConfig) { - const FormButton = ` - - - - - - `; - const raw = ` - import React from 'react'; - import { - Column, - ColumnDef, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - PaginationState, - SortingState, - useReactTable, - } from '@tanstack/react-table'; - import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react'; - import { useIntl } from 'react-intl'; - import { Badge } from '#shared/ui/Badge'; - import { CopyButton } from '#shared/ui/CopyButton'; - import { Button } from '#shared/ui/Button'; - import { - Card, - CardFooter, - CardHeader, - CardTable, - CardTitle, - CardToolbar, - } from '#shared/ui/Card'; - import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid'; - import { Input, InputWrapper } from '#shared/ui/Input'; - import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea'; - import { Skeleton } from '#shared/ui/Skeleton'; - import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery'; - import { useDebounceSearch } from '@crux-kit/hooks'; - import { PermissionGate } from '#core/Permission'; - import { useGetList } from '../../api/list'; - import { - MODULE_NAME_LABEL, - MODULE_PERMISSION_CREATE, - MODULE_PERMISSION_DELETE, - MODULE_PERMISSION_UPDATE, - } from '../../config/meta'; - import { Entity } from '../../entities/Entity'; - import { DeleteTrigger } from './DeleteTrigger'; - ${this.shouldAddEditForm(data.generate) ? "import { ManageTrigger } from './ManageTrigger';" : ''} - - interface IColumnFilterProps { - column: Column - }; - - export const DataTable = () => { - const intl = useIntl(); - - const [pagination, setPagination] = React.useState({ - pageIndex: 0, - pageSize: 10, - }); - - const [search, setSearch] = React.useState(''); - - const { handleChange } = useDebounceSearch({ - minQueryNumber: 3, - delay: 400, - initialQuery: search, - onTimeOut: setSearch, - }); - - const [sorting, setSorting] = React.useState([]); - - const { list, total, isFetching, isLoading, pagesCount } = useGetList( - mapTableStateToListQueryParams(pagination, sorting, search) - ); - - const columns = React.useMemo[]>( - () => [${this.createColumnsHTML(data)}], - [intl] - ); - - const table = useReactTable({ - columns, - autoResetPageIndex: false, - enableMultiSort: false, - data: list, - manualPagination: true, - pageCount: pagesCount, - getRowId: (row) => String(row.id), - state: { - pagination, - sorting, - }, - onPaginationChange: setPagination, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - - return ( - - - - - - {search.length > 0 && isFetching ? ( - - ) : ( - - )} - handleChange(e.target.value)} - /> - - - - - - - - - - - - - - - - ) - }; - - const Toolbar = () => { - const { table } = useDataGrid(); - const intl = useIntl(); - - return ( - -
- ${this.shouldAddEditForm(data.generate) ? FormButton : ''} - - - {intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })} - - } - /> -
-
- ); - }; - - const ColumnInputFilter = ({ column }: IColumnFilterProps) => { - const intl = useIntl(); - - return ( - column.setFilterValue(event.target.value)} - className="h-9 w-full max-w-40" - /> - ); - }; - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } - - private shouldAddEditForm(assets: ModuleAsset[]): boolean { - return assets.includes(ModuleAsset.FORM); - } - - private createColumnsHTML(ctx: ModuleGeneratorConfig) { - const data: ModuleTableConfig = ctx.datatable; - const columns: string[] = []; - - data.columns - .filter((column) => column.hidden === false) - .forEach((column) => { - const name = column.value; - - if (column.isActionColumn) { - columns.push(this.createActionColumn(column, ctx)); - return; - } - if (name === 'id' || name === 'uuid') { - columns.push(this.createIdColumn(column)); - return; - } - - columns.push(this.createBaseColumn(column)); - }); - - return columns.join(',\n'); - } - - private createBaseColumn(data: ModuleTableColumn) { - const enableSorting = data.innerProps.sort ? 'true' : 'false'; - const baseCellContent = '(info) => info.getValue()'; - - let cellContent = baseCellContent; - - if (data.innerProps.badge) { - const cellContentWithBadge = `(info) => {String(info.getValue())}`; - cellContent = cellContentWithBadge; - } - - if (data.innerProps.copy) { - const cellContentWithCopy = ` - (info) => { - const val = String(info.getValue()); - return ( -
- {val} - -
- ); - } - `; - cellContent = cellContentWithCopy; - } - - return ` - { - id: '${data.value}', - accessorFn: (row) => row.${data.value}, - header: ({ column }) => ( - } - column={column} - /> - ), - cell: ${cellContent}, - enableSorting: ${enableSorting}, - meta: { - cellClassName: '', - skeleton: , - }, - } - `; - } - - private createIdColumn(data: ModuleTableColumn) { - const baseCellContent = '(info) => info.getValue()'; - - let cellContent = baseCellContent; - - if (data.innerProps.badge) { - const cellContentWithBadge = `(info) => {String(info.getValue())}`; - cellContent = cellContentWithBadge; - } - - if (data.innerProps.copy) { - const cellContentWithCopy = ` - (info) => { - const val = String(info.getValue()); - return ( -
- {val} - -
- ); - } - `; - cellContent = cellContentWithCopy; - } - - return ` - { - id: '${data.value}', - accessorFn: (row) => row.id, - header: ({ column }) => , - cell: ${cellContent}, - enableSorting: true, - size: 80, - meta: { - cellClassName: '', - skeleton: , - }, - } - `; - } - - private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) { - const EditFormButton = ` - - - - - - `; - const DeleteRowButton = ` - - - - `; - - return ` - { - id: '${data.value}', - header: '${data.label}', - cell: (info) => { - const data = info.row.original; - const id = data.id; - - return ( -
- ${this.shouldAddEditForm(ctx.generate) && EditFormButton} - ${DeleteRowButton} -
- ); - }, - enableSorting: false, - size: 96, - meta: { - headerClassName: '', - skeleton: , - }, - } - `; - } -} - -export default new DataTableTemplateBuilder(); diff --git a/apps/admin/src/devtools/data/builders/formTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/FormSourceBuilder.ts similarity index 84% rename from apps/admin/src/devtools/data/builders/formTemplateBuilder.ts rename to apps/admin/src/devtools/data/builders/FormSourceBuilder.ts index 9eacda7..2bd30f2 100644 --- a/apps/admin/src/devtools/data/builders/formTemplateBuilder.ts +++ b/apps/admin/src/devtools/data/builders/FormSourceBuilder.ts @@ -1,74 +1,95 @@ import { ModuleFieldType } from '#devtools/entities/ModuleFieldType'; import { ModuleGridCell, ModuleLayoutConfig } from '#devtools/entities/ModuleGeneratorTypes'; +import { indentSource, SourceFile, source } from './shared/SourceFile'; -class FormTemplateBuilder { +class FormSourceBuilder { buildComponent(data: ModuleLayoutConfig) { - const raw = ` - import React from 'react'; - import { useForm } from 'react-hook-form'; - import { zodResolver } from '@hookform/resolvers/zod'; - import { Form as FormProvider } from '#shared/ui/form'; - import * as Component from '#shared/forms/composed'; - import { FORM_ID } from './constants'; - import { FormValues, SubmitFunc, validationSchema } from './form'; + const file = new SourceFile(); - export const MainForm: React.FC<{ - initialValues: FormValues; - onSubmit: SubmitFunc; - }> = ({ initialValues, onSubmit }) => { - const form = useForm({ - values: initialValues, - resolver: zodResolver(validationSchema), - }); + file.addImports([ + "import React from 'react';", + "import { useForm } from 'react-hook-form';", + "import { zodResolver } from '@hookform/resolvers/zod';", + "import { Form as FormProvider } from '#shared/ui/form';", + "import * as Component from '#shared/forms/composed';", + "import { FORM_ID } from './constants';", + "import { FormValues, SubmitFunc, validationSchema } from './form';" + ]); - return ( - - onSubmit(values, form))} - > - - ${this.createFieldsHTML(data)} - - - - ) - } - `; + file.addSection(this.buildMainFormComponent(data)); - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); + return file.toString(); } buildDefinition(data: ModuleLayoutConfig) { - const raw = ` - import { UseFormReturn } from 'react-hook-form'; - import z from 'zod'; - import { e164ToMask } from '#shared/lib/phoneNumber'; - import { Entity } from '../../../entities/Entity'; + const file = new SourceFile(); - export const validationSchema = ${this.createValidationSchema(data)}; + file.addImports([ + "import { UseFormReturn } from 'react-hook-form';", + "import z from 'zod';", + "import { Entity } from '../../../entities/Entity';" + ]); - export type FormValues = z.infer; - - export type SubmitFunc = (values: FormValues, handlers: UseFormReturn) => void; - - export function prepareInitialValues(data: Entity): FormValues { - if (data == null) return initialValues; - - return ${this.createPreparedValues(data)} + if (data.grid.find((item) => item.body?.type === ModuleFieldType.PHONE)) { + file.addImport("import { e164ToMask } from '#shared/lib/phoneNumber';"); } - export const initialValues: FormValues = ${this.createInitialValues(data)}; - `; + file.addSection(this.buildFormDefinition(data)); - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); + return file.toString(); } - private createFieldsHTML(formConfig: ModuleLayoutConfig): string { + private buildMainFormComponent(data: ModuleLayoutConfig) { + return ` + export const MainForm: React.FC<{ + initialValues: FormValues; + onSubmit: SubmitFunc; + }> = ({ initialValues, onSubmit }) => { + const form = useForm({ + values: initialValues, + resolver: zodResolver(validationSchema), + }); + + return ( + + onSubmit(values, form))} + > + +${indentSource(this.createFieldsSource(data), 16)} + + + + ) + } + `; + } + + private buildFormDefinition(data: ModuleLayoutConfig) { + return ` + export const validationSchema = ${source(this.createValidationSchema(data))}; + + export type FormValues = z.infer; + + export type SubmitFunc = (values: FormValues, handlers: UseFormReturn) => void; + + export function prepareInitialValues(data: Entity): FormValues { + if (data == null) return initialValues; + + return ${this.createPreparedValues(data)} + } + + export const initialValues: FormValues = ${this.createInitialValues(data)}; + `; + } + + private createFieldsSource(formConfig: ModuleLayoutConfig): string { return formConfig.grid .map((cell) => this.mapGridCellToComponent(cell)) - .filter((str) => str != null) - .join(''); + .filter((str): str is string => str != null) + .map(source) + .join('\n'); } private createInitialValues(formConfig: ModuleLayoutConfig): string { @@ -589,4 +610,4 @@ class FormTemplateBuilder { } } -export default new FormTemplateBuilder(); +export default new FormSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/ListPageSourceBuilder.ts b/apps/admin/src/devtools/data/builders/ListPageSourceBuilder.ts new file mode 100644 index 0000000..09b4652 --- /dev/null +++ b/apps/admin/src/devtools/data/builders/ListPageSourceBuilder.ts @@ -0,0 +1,43 @@ +import { ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes'; +import { SourceFile } from './shared/SourceFile'; + +class ListPageSourceBuilder { + build(_data: ModuleTableConfig, params: { addTable: boolean }) { + const file = new SourceFile(); + + file.addImports([ + "import React from 'react';", + "import { Container } from '#shared/widgets/common/Container';", + "import { PermissionPageGate } from '#core/Permission';", + "import { MODULE_PERMISSION_VIEW } from '../../config/meta';" + ]); + + if (params.addTable) { + file.addImport("import { DataTable } from '../../view/widgets/DataTable';"); + } + + file.addSection(this.buildComponent(params.addTable ? '' : '')); + + return file.toString(); + } + + private buildComponent(tableSource: string) { + return ` + const ListPage: React.FC = () => { + return ( + + + + ${tableSource} + + + + ); + }; + + export default ListPage; + `; + } +} + +export default new ListPageSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/ManageTriggerSourceBuilder.ts b/apps/admin/src/devtools/data/builders/ManageTriggerSourceBuilder.ts new file mode 100644 index 0000000..7c951a8 --- /dev/null +++ b/apps/admin/src/devtools/data/builders/ManageTriggerSourceBuilder.ts @@ -0,0 +1,221 @@ +import { ModuleFormContainer } from '#devtools/entities/ModuleGeneratorTypes'; +import { SourceFile } from './shared/SourceFile'; + +type DialogSize = 'default' | 'md' | 'fullscreen'; +type SheetSide = 'top' | 'right' | 'bottom' | 'left'; + +interface ManageTriggerContainerConfig { + body: string; + close: string; + content: string; + contentProps: string; + description: string; + disclosureName: string; + footer: string; + header: string; + root: string; + title: string; + trigger: string; +} + +class ManageTriggerSourceBuilder { + build(container: ModuleFormContainer) { + if (this.isSheet(container)) { + return this.buildSheet(this.getSheetSide(container)); + } + + return this.buildDialog(this.getDialogSize(container)); + } + + private isSheet( + container: ModuleFormContainer + ): container is Extract { + return container.startsWith('sheet-'); + } + + private getSheetSide(container: Extract): SheetSide { + switch (container) { + case 'sheet-top': + return 'top'; + case 'sheet-right': + return 'right'; + case 'sheet-bottom': + return 'bottom'; + case 'sheet-left': + return 'left'; + } + } + + private getDialogSize(container: Extract): DialogSize { + switch (container) { + case 'dialog-default': + return 'default'; + case 'dialog-md': + return 'md'; + case 'dialog-fullscreen': + return 'fullscreen'; + } + } + + private buildDialog(dialogSize: DialogSize) { + const file = new SourceFile(); + + file.addImports([ + "import React from 'react';", + "import { LoaderCircleIcon } from 'lucide-react';", + "import { useIntl } from 'react-intl';", + "import { Button } from '#shared/ui/Button';", + ` + import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + } from '#shared/ui/Dialog'; + `, + "import { Skeleton } from '#shared/ui/Skeleton';", + "import { useDisclosure } from '@crux-kit/hooks';", + "import { useGetDetailed } from '../../api/detailed';", + "import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';" + ]); + + file.addSection( + this.buildManageTriggerComponent({ + body: 'DialogBody', + close: 'DialogClose', + content: 'DialogContent', + contentProps: `variant="${dialogSize}"`, + description: 'DialogDescription', + disclosureName: 'dialog', + footer: 'DialogFooter', + header: 'DialogHeader', + root: 'Dialog', + title: 'DialogTitle', + trigger: 'DialogTrigger' + }) + ); + + return file.toString(); + } + + private buildSheet(side: SheetSide) { + const file = new SourceFile(); + + file.addImports([ + "import React from 'react';", + "import { LoaderCircleIcon } from 'lucide-react';", + "import { useIntl } from 'react-intl';", + "import { Button } from '#shared/ui/Button';", + ` + import { + Sheet, + SheetBody, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, + } from '#shared/ui/Sheet'; + `, + "import { Skeleton } from '#shared/ui/Skeleton';", + "import { useDisclosure } from '@crux-kit/hooks';", + "import { useGetDetailed } from '../../api/detailed';", + "import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';" + ]); + + file.addSection( + this.buildManageTriggerComponent({ + body: 'SheetBody', + close: 'SheetClose', + content: 'SheetContent', + contentProps: `side="${side}" className="max-h-dvh"`, + description: 'SheetDescription', + disclosureName: 'sheet', + footer: 'SheetFooter', + header: 'SheetHeader', + root: 'Sheet', + title: 'SheetTitle', + trigger: 'SheetTrigger' + }) + ); + + return file.toString(); + } + + private buildManageTriggerComponent(config: ManageTriggerContainerConfig) { + const bodyProps = config.body === 'SheetBody' ? ' className="overflow-y-auto"' : ''; + + return ` + export const ManageTrigger: React.FC< + React.PropsWithChildren<{ + title: string; + id: number | null; + }> + > = ({ id, children, title }) => { + const intl = useIntl(); + + const ${config.disclosureName} = useDisclosure(false); + + const { data, isLoading } = useGetDetailed( + id == null || ${config.disclosureName}.isOpen === false ? undefined : { id: String(id) }, + ); + + const { isSubmitting, submit } = useSubmit({ onSuccess: ${config.disclosureName}.close }); + + return ( + <${config.root} open={${config.disclosureName}.isOpen} onOpenChange={${config.disclosureName}.toggle}> + <${config.trigger} asChild>{children} + <${config.content} ${config.contentProps}> + <${config.header}> + <${config.title}>{title} + <${config.description} className="sr-only"> + + <${config.body}${bodyProps}> + {isLoading ? ( +
+ + + + +
+ ) : ( + + )} + + <${config.footer}> + + + <${config.close} asChild> + + + + + + ); + }; + `; + } +} + +export default new ManageTriggerSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/MenuSourceBuilder.ts b/apps/admin/src/devtools/data/builders/MenuSourceBuilder.ts new file mode 100644 index 0000000..bc16a01 --- /dev/null +++ b/apps/admin/src/devtools/data/builders/MenuSourceBuilder.ts @@ -0,0 +1,30 @@ +import { SourceFile } from './shared/SourceFile'; + +class MenuSourceBuilder { + build() { + const file = new SourceFile(); + + file.addImports([ + "import { NotepadText } from 'lucide-react'", + "import { IntlShape } from 'react-intl'", + "import { MenuItem } from '#app/config/types'", + "import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta'" + ]); + + file.addSection(` + export default (intl: IntlShape): MenuItem => ({ + title: intl.formatMessage({ + id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`, + defaultMessage: MODULE_NAME_LABEL, + }), + icon: NotepadText, + path: MODULE_ROOT_PATH, + permissions: [MODULE_PERMISSION_VIEW], + }) + `); + + return file.toString(); + } +} + +export default new MenuSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/ModuleConfigSourceBuilder.ts b/apps/admin/src/devtools/data/builders/ModuleConfigSourceBuilder.ts new file mode 100644 index 0000000..4d2cff1 --- /dev/null +++ b/apps/admin/src/devtools/data/builders/ModuleConfigSourceBuilder.ts @@ -0,0 +1,30 @@ +import { SourceFile } from './shared/SourceFile'; + +class ModuleConfigSourceBuilder { + build(moduleId: string) { + const file = new SourceFile(); + + file.addImports([ + "import { AppModuleConfig } from '#app/services/modules/types';", + "import menu from './menu';", + "import messages from './messages';", + "import routes from './routing';" + ]); + + file.addSection(` + export default { + id: '${moduleId}', + area: 'business', + routeScope: 'protected', + order: 100, + routes, + menu, + messages, + } satisfies AppModuleConfig; + `); + + return file.toString(); + } +} + +export default new ModuleConfigSourceBuilder(); diff --git a/apps/admin/src/devtools/data/builders/formDialogTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/formDialogTemplateBuilder.ts deleted file mode 100644 index 82bc193..0000000 --- a/apps/admin/src/devtools/data/builders/formDialogTemplateBuilder.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { ModuleFormContainer } from '#devtools/entities/ModuleGeneratorTypes'; - -class FormDialogTemplateBuilder { - build(container: ModuleFormContainer) { - if (this.isSheet(container)) { - return this.buildSheet(this.getSheetSide(container)); - } - - return this.buildDialog(this.getDialogSize(container)); - } - - private isSheet( - container: ModuleFormContainer - ): container is Extract { - return container.startsWith('sheet-'); - } - - private getSheetSide( - container: Extract - ): 'top' | 'right' | 'bottom' | 'left' { - switch (container) { - case 'sheet-top': - return 'top'; - case 'sheet-right': - return 'right'; - case 'sheet-bottom': - return 'bottom'; - case 'sheet-left': - return 'left'; - } - } - - private getDialogSize( - container: Extract - ): 'default' | 'md' | 'fullscreen' { - switch (container) { - case 'dialog-default': - return 'default'; - case 'dialog-md': - return 'md'; - case 'dialog-fullscreen': - return 'fullscreen'; - } - } - - private buildDialog(dialogSize: 'default' | 'md' | 'fullscreen') { - const raw = ` - import React from 'react'; - import { LoaderCircleIcon } from 'lucide-react'; - import { useIntl } from 'react-intl'; - import { Button } from '#shared/ui/Button'; - import { - Dialog, - DialogBody, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - } from '#shared/ui/Dialog'; - import { Skeleton } from '#shared/ui/Skeleton'; - import { useDisclosure } from '@crux-kit/hooks'; - import { useGetDetailed } from '../../api/detailed'; - import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm'; - - export const ManageTrigger: React.FC< - React.PropsWithChildren<{ - title: string; - id: number | null; - }> - > = ({ id, children, title }) => { - const intl = useIntl(); - - const dialog = useDisclosure(false); - - const { data, isLoading } = useGetDetailed( - id == null || dialog.isOpen === false ? undefined : { id: String(id) }, - ); - - const { isSubmitting, submit } = useSubmit({ onSuccess: dialog.close }); - - return ( - - {children} - - - {title} - - - - {isLoading ? ( -
- - - - -
- ) : ( - - )} -
- - - - - - - -
-
- ); - }; - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } - - private buildSheet(side: 'top' | 'right' | 'bottom' | 'left') { - const raw = ` - import React from 'react'; - import { LoaderCircleIcon } from 'lucide-react'; - import { useIntl } from 'react-intl'; - import { Button } from '#shared/ui/Button'; - import { - Sheet, - SheetBody, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, - } from '#shared/ui/Sheet'; - import { Skeleton } from '#shared/ui/Skeleton'; - import { useDisclosure } from '@crux-kit/hooks'; - import { useGetDetailed } from '../../api/detailed'; - import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm'; - - export const ManageTrigger: React.FC< - React.PropsWithChildren<{ - title: string; - id: number | null; - }> - > = ({ id, children, title }) => { - const intl = useIntl(); - - const sheet = useDisclosure(false); - - const { data, isLoading } = useGetDetailed( - id == null || sheet.isOpen === false ? undefined : { id: String(id) }, - ); - - const { isSubmitting, submit } = useSubmit({ onSuccess: sheet.close }); - - return ( - - {children} - - - {title} - - - - {isLoading ? ( -
- - - - -
- ) : ( - - )} -
- - - - - - - -
-
- ); - }; - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } -} - -export default new FormDialogTemplateBuilder(); diff --git a/apps/admin/src/devtools/data/builders/listPageTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/listPageTemplateBuilder.ts deleted file mode 100644 index 2401ca5..0000000 --- a/apps/admin/src/devtools/data/builders/listPageTemplateBuilder.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes'; - -class ListPageTemplateBuilder { - build(_data: ModuleTableConfig, params: { addTable: boolean }) { - const imports = [ - "import React from 'react';", - "import { Container } from '#shared/widgets/common/Container';", - "import { PermissionPageGate } from '#core/Permission';", - "import { MODULE_PERMISSION_VIEW } from '../../config/meta';" - ]; - - let table = ''; - - if (params.addTable) { - imports.push("import { DataTable } from '../../view/widgets/DataTable';"); - table = ''; - } - - const raw = ` - ${imports.join('\n')} - \n - const ListPage: React.FC = () => { - return ( - - - - ${table} - - - - ); - }; - - export default ListPage; - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } -} - -export default new ListPageTemplateBuilder(); diff --git a/apps/admin/src/devtools/data/builders/menuTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/menuTemplateBuilder.ts deleted file mode 100644 index 95ed8f9..0000000 --- a/apps/admin/src/devtools/data/builders/menuTemplateBuilder.ts +++ /dev/null @@ -1,24 +0,0 @@ -class MenuTemplateBuilder { - build() { - const raw = ` - import { NotepadText } from 'lucide-react' - import { IntlShape } from 'react-intl' - import { MenuItem } from '#app/config/types' - import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta' - - export default (intl: IntlShape): MenuItem => ({ - title: intl.formatMessage({ - id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`, - defaultMessage: MODULE_NAME_LABEL, - }), - icon: NotepadText, - path: MODULE_ROOT_PATH, - permissions: [MODULE_PERMISSION_VIEW], - }) - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } -} - -export default new MenuTemplateBuilder(); diff --git a/apps/admin/src/devtools/data/builders/moduleTemplateBuilder.ts b/apps/admin/src/devtools/data/builders/moduleTemplateBuilder.ts deleted file mode 100644 index 49ae439..0000000 --- a/apps/admin/src/devtools/data/builders/moduleTemplateBuilder.ts +++ /dev/null @@ -1,24 +0,0 @@ -class ModuleTemplateBuilder { - build(moduleId: string) { - const raw = ` - import { AppModuleConfig } from '#app/services/modules/types'; - import menu from './menu'; - import messages from './messages'; - import routes from './routing'; - - export default { - id: '${moduleId}', - area: 'business', - routeScope: 'protected', - order: 100, - routes, - menu, - messages, - } satisfies AppModuleConfig; - `; - - return raw.replace(/\{\s*'\s*'\s*\}/g, ''); - } -} - -export default new ModuleTemplateBuilder(); diff --git a/apps/admin/src/devtools/data/builders/shared/SourceFile.ts b/apps/admin/src/devtools/data/builders/shared/SourceFile.ts new file mode 100644 index 0000000..dba70aa --- /dev/null +++ b/apps/admin/src/devtools/data/builders/shared/SourceFile.ts @@ -0,0 +1,65 @@ +type SourceChunk = string | null | false | undefined; + +const EMPTY_JSX_TEXT_EXPRESSION_REGEX = /\{\s*'\s*'\s*\}/g; + +function normalizeSource(value: string): string { + const source = value.replace(/^\n/, '').replace(/\n\s*$/, ''); + const lines = source.split('\n'); + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^\s*/)?.[0].length ?? 0); + + const minIndent = indents.length > 0 ? Math.min(...indents) : 0; + + return lines + .map((line) => line.slice(Math.min(line.match(/^\s*/)?.[0].length ?? 0, minIndent))) + .join('\n') + .replace(EMPTY_JSX_TEXT_EXPRESSION_REGEX, '') + .trim(); +} + +export function source(value: string): string { + return normalizeSource(value); +} + +export function indentSource(value: string, spaces: number): string { + const prefix = ' '.repeat(spaces); + + return normalizeSource(value) + .split('\n') + .map((line) => (line.length > 0 ? `${prefix}${line}` : line)) + .join('\n'); +} + +export class SourceFile { + private readonly imports = new Set(); + private readonly sections: string[] = []; + + addImport(value: SourceChunk): this { + if (value) { + this.imports.add(normalizeSource(value)); + } + + return this; + } + + addImports(values: SourceChunk[]): this { + values.forEach((value) => this.addImport(value)); + + return this; + } + + addSection(value: SourceChunk): this { + if (value) { + this.sections.push(normalizeSource(value)); + } + + return this; + } + + toString(): string { + return [Array.from(this.imports).join('\n'), this.sections.join('\n\n')] + .filter((section) => section.length > 0) + .join('\n\n'); + } +} diff --git a/apps/admin/src/devtools/pages/ModuleGeneratorPage/lib/buildGenerateModulePayload.ts b/apps/admin/src/devtools/pages/ModuleGeneratorPage/lib/buildGenerateModulePayload.ts index 1032b83..246eab6 100644 --- a/apps/admin/src/devtools/pages/ModuleGeneratorPage/lib/buildGenerateModulePayload.ts +++ b/apps/admin/src/devtools/pages/ModuleGeneratorPage/lib/buildGenerateModulePayload.ts @@ -1,10 +1,10 @@ import { GenerateModulePayload } from '#devtools/data/api/dto'; -import DataTableTemplateBuilder from '#devtools/data/builders/DataTableTemplateBuilder'; -import FormDialogTemplateBuilder from '#devtools/data/builders/formDialogTemplateBuilder'; -import FormTemplateBuilder from '#devtools/data/builders/FormTemplateBuilder'; -import ListPageTemplateBuilder from '#devtools/data/builders/ListPageTemplateBuilder'; -import MenuTemplateBuilder from '#devtools/data/builders/MenuTemplateBuilder'; -import ModuleTemplateBuilder from '#devtools/data/builders/ModuleTemplateBuilder'; +import DataTableSourceBuilder from '#devtools/data/builders/DataTableSourceBuilder'; +import FormSourceBuilder from '#devtools/data/builders/FormSourceBuilder'; +import ListPageSourceBuilder from '#devtools/data/builders/ListPageSourceBuilder'; +import ManageTriggerSourceBuilder from '#devtools/data/builders/ManageTriggerSourceBuilder'; +import MenuSourceBuilder from '#devtools/data/builders/MenuSourceBuilder'; +import ModuleConfigSourceBuilder from '#devtools/data/builders/ModuleConfigSourceBuilder'; import { ModuleAsset } from '#devtools/entities/ModuleAsset'; import { ModuleGeneratorConfig } from '#devtools/entities/ModuleGeneratorTypes'; @@ -18,14 +18,12 @@ export default function buildGenerateModulePayload( name: values.name, table: values.table, generate: values.generate, - form: shouldGenerateForm ? FormTemplateBuilder.buildComponent(values.form) : null, - formDefinition: shouldGenerateForm ? FormTemplateBuilder.buildDefinition(values.form) : null, - dataTable: shouldGenerateDataTable ? DataTableTemplateBuilder.build(values) : null, - list: ListPageTemplateBuilder.build(values.datatable, { - addTable: shouldGenerateDataTable - }), - formDialog: shouldGenerateForm ? FormDialogTemplateBuilder.build(values.form.size) : null, - menu: shouldGenerateDataTable ? MenuTemplateBuilder.build() : null, - module: ModuleTemplateBuilder.build(values.name) + form: shouldGenerateForm ? FormSourceBuilder.buildComponent(values.form) : null, + formDefinition: shouldGenerateForm ? FormSourceBuilder.buildDefinition(values.form) : null, + dataTable: shouldGenerateDataTable ? DataTableSourceBuilder.build(values) : null, + list: ListPageSourceBuilder.build(values.datatable, { addTable: shouldGenerateDataTable }), + formDialog: shouldGenerateForm ? ManageTriggerSourceBuilder.build(values.form.size) : null, + menu: shouldGenerateDataTable ? MenuSourceBuilder.build() : null, + module: ModuleConfigSourceBuilder.build(values.name) }; } diff --git a/docs/devtools/module-generator/frontend.md b/docs/devtools/module-generator/frontend.md index e1b7e78..066aaf2 100644 --- a/docs/devtools/module-generator/frontend.md +++ b/docs/devtools/module-generator/frontend.md @@ -53,15 +53,15 @@ Table and column requests are cached through React Query. Column queries remain `buildGenerateModulePayload` delegates source generation to focused builders: -| Builder | Payload field | Generated destination | -| ------------------------------------- | ---------------- | ----------------------------------- | -| `FormTemplateBuilder.buildComponent` | `form` | `view/forms/MainForm/Component.tsx` | -| `FormTemplateBuilder.buildDefinition` | `formDefinition` | `view/forms/MainForm/form.ts` | -| `FormDialogTemplateBuilder` | `formDialog` | `view/widgets/ManageTrigger.tsx` | -| `DataTableTemplateBuilder` | `dataTable` | `view/widgets/DataTable.tsx` | -| `ListPageTemplateBuilder` | `list` | `pages/ListPage/index.tsx` | -| `MenuTemplateBuilder` | `menu` | `config/menu.ts` | -| `ModuleTemplateBuilder` | `module` | `config/module.ts` | +| Builder | Payload field | Generated destination | +| ----------------------------------- | ---------------- | ----------------------------------- | +| `FormSourceBuilder.buildComponent` | `form` | `view/forms/MainForm/Component.tsx` | +| `FormSourceBuilder.buildDefinition` | `formDefinition` | `view/forms/MainForm/form.ts` | +| `ManageTriggerSourceBuilder` | `formDialog` | `view/widgets/ManageTrigger.tsx` | +| `DataTableSourceBuilder` | `dataTable` | `view/widgets/DataTable.tsx` | +| `ListPageSourceBuilder` | `list` | `pages/ListPage/index.tsx` | +| `MenuSourceBuilder` | `menu` | `config/menu.ts` | +| `ModuleConfigSourceBuilder` | `module` | `config/module.ts` | If form generation is disabled, `form`, `formDefinition`, and `formDialog` are `null`. If datatable generation is disabled, `dataTable` and `menu` are `null`, while the list-page builder emits a page without a table.