Feature/template builder rework

This commit is contained in:
Igor Martyniuk
2026-07-06 13:57:21 +00:00
parent bc52d9afd4
commit 7d79b2fb30
15 changed files with 874 additions and 750 deletions
@@ -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<TData, TValue> {
column: Column<TData, TValue>
};
`;
}
private buildDataTableComponent(data: ModuleGeneratorConfig) {
return `
export const DataTable = () => {
const intl = useIntl();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [search, setSearch] = React.useState<string>('');
const { handleChange } = useDebounceSearch({
minQueryNumber: 3,
delay: 400,
initialQuery: search,
onTimeOut: setSearch,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
mapTableStateToListQueryParams(pagination, sorting, search)
);
const columns = React.useMemo<ColumnDef<Entity>[]>(
() => [
${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 (
<DataGrid
table={table}
recordCount={total || 0}
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
loadingMode="skeleton"
isLoading={isLoading}
tableLayout={{
columnsDraggable: false,
columnsResizable: false,
width: 'auto',
columnsPinnable: true,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true,
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
{search.length > 0 && isFetching ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
<Input
type="text"
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
onChange={(e) => handleChange(e.target.value)}
/>
</InputWrapper>
</CardTitle>
<Toolbar />
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
<CardFooter>
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
};
`;
}
private buildToolbarComponent(data: ModuleGeneratorConfig) {
return `
const Toolbar = () => {
const { table } = useDataGrid();
const intl = useIntl();
return (
<CardToolbar>
<div className="flex flex-wrap items-center gap-2.5">
${this.shouldAddEditForm(data.generate) ? indentSource(this.buildCreateButton(), 14) : ''}
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline">
<Settings2 />
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
</Button>
}
/>
</div>
</CardToolbar>
);
};
`;
}
private buildColumnInputFilterComponent() {
return `
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
const intl = useIntl();
return (
<Input
placeholder={intl.formatMessage({ id: 'FILTER' })}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className="h-9 w-full max-w-40"
/>
);
};
`;
}
private buildCreateButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
<ManageTrigger
id={null}
title={intl.formatMessage(
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
{ entity: MODULE_NAME_LABEL }
)}
>
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
</ManageTrigger>
</PermissionGate>
`;
}
private buildEditButton() {
return `
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
<Button variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</ManageTrigger>
</PermissionGate>
`;
}
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 }) => (
<DataGridColumnHeader
title="${data.label}"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: ${source(this.createCellContent(data))},
enableSorting: ${enableSorting},
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
private createIdColumn(data: ModuleTableColumn) {
return `
{
id: '${data.value}',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
cell: ${source(this.createCellContent(data))},
enableSorting: true,
size: 80,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
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 (
<div className="flex items-center gap-2 justify-end">
${editButtonSource ? indentSource(editButtonSource, 14) : ''}
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
<DeleteTrigger id={id} deleteTarget={id} />
</PermissionGate>
</div>
);
},
enableSorting: false,
size: 96,
meta: {
headerClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
private createCellContent(data: ModuleTableColumn) {
if (data.innerProps.copy) {
return `
(info) => {
const val = String(info.getValue());
return (
<div className="flex items-center justify-between">
<code className="line-clamp-1">{val}</code>
<CopyButton copyText={val} />
</div>
);
}
`;
}
if (data.innerProps.badge) {
return `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
}
return '(info) => info.getValue()';
}
}
export default new DataTableSourceBuilder();
@@ -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 = `
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
<ManageTrigger
id={null}
title={intl.formatMessage(
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
{ entity: MODULE_NAME_LABEL }
)}
>
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
</ManageTrigger>
</PermissionGate>
`;
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<TData, TValue> {
column: Column<TData, TValue>
};
export const DataTable = () => {
const intl = useIntl();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [search, setSearch] = React.useState<string>('');
const { handleChange } = useDebounceSearch({
minQueryNumber: 3,
delay: 400,
initialQuery: search,
onTimeOut: setSearch,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
mapTableStateToListQueryParams(pagination, sorting, search)
);
const columns = React.useMemo<ColumnDef<Entity>[]>(
() => [${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 (
<DataGrid
table={table}
recordCount={total || 0}
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
loadingMode="skeleton"
isLoading={isLoading}
tableLayout={{
columnsDraggable: false,
columnsResizable: false,
width: 'auto',
columnsPinnable: true,
columnsMovable: true,
columnsVisibility: true,
cellBorder: true,
}}
>
<Card>
<CardHeader>
<CardTitle>
<InputWrapper>
{search.length > 0 && isFetching ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<SearchIcon className="size-4" />
)}
<Input
type="text"
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
onChange={(e) => handleChange(e.target.value)}
/>
</InputWrapper>
</CardTitle>
<Toolbar />
</CardHeader>
<CardTable>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardTable>
<CardFooter>
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
};
const Toolbar = () => {
const { table } = useDataGrid();
const intl = useIntl();
return (
<CardToolbar>
<div className="flex flex-wrap items-center gap-2.5">
${this.shouldAddEditForm(data.generate) ? FormButton : ''}
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline">
<Settings2 />
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
</Button>
}
/>
</div>
</CardToolbar>
);
};
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
const intl = useIntl();
return (
<Input
placeholder={intl.formatMessage({ id: 'FILTER' })}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => 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) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
cellContent = cellContentWithBadge;
}
if (data.innerProps.copy) {
const cellContentWithCopy = `
(info) => {
const val = String(info.getValue());
return (
<div className="flex items-center justify-between">
<code className="line-clamp-1">{val}</code>
<CopyButton copyText={val} />
</div>
);
}
`;
cellContent = cellContentWithCopy;
}
return `
{
id: '${data.value}',
accessorFn: (row) => row.${data.value},
header: ({ column }) => (
<DataGridColumnHeader
title="${data.label}"
filter={<ColumnInputFilter column={column} />}
column={column}
/>
),
cell: ${cellContent},
enableSorting: ${enableSorting},
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
private createIdColumn(data: ModuleTableColumn) {
const baseCellContent = '(info) => info.getValue()';
let cellContent = baseCellContent;
if (data.innerProps.badge) {
const cellContentWithBadge = `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
cellContent = cellContentWithBadge;
}
if (data.innerProps.copy) {
const cellContentWithCopy = `
(info) => {
const val = String(info.getValue());
return (
<div className="flex items-center justify-between">
<code className="line-clamp-1">{val}</code>
<CopyButton copyText={val} />
</div>
);
}
`;
cellContent = cellContentWithCopy;
}
return `
{
id: '${data.value}',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
cell: ${cellContent},
enableSorting: true,
size: 80,
meta: {
cellClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) {
const EditFormButton = `
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
<Button variant="secondary" size="sm" shape="default" mode="icon">
<SquarePen />
</Button>
</ManageTrigger>
</PermissionGate>
`;
const DeleteRowButton = `
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
<DeleteTrigger id={id} deleteTarget={id} />
</PermissionGate>
`;
return `
{
id: '${data.value}',
header: '${data.label}',
cell: (info) => {
const data = info.row.original;
const id = data.id;
return (
<div className="flex items-center gap-2 justify-end">
${this.shouldAddEditForm(ctx.generate) && EditFormButton}
${DeleteRowButton}
</div>
);
},
enableSorting: false,
size: 96,
meta: {
headerClassName: '',
skeleton: <Skeleton className="h-4" />,
},
}
`;
}
}
export default new DataTableTemplateBuilder();
@@ -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<FormValues>({
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 (
<FormProvider {...form}>
<Component.FormElement
id={FORM_ID}
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
>
<Component.FormGrid columns={${data.cols}}>
${this.createFieldsHTML(data)}
</Component.FormGrid>
</Component.FormElement>
</FormProvider>
)
}
`;
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<typeof validationSchema>;
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => 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<FormValues>({
values: initialValues,
resolver: zodResolver(validationSchema),
});
return (
<FormProvider {...form}>
<Component.FormElement
id={FORM_ID}
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
>
<Component.FormGrid columns={${data.cols}}>
${indentSource(this.createFieldsSource(data), 16)}
</Component.FormGrid>
</Component.FormElement>
</FormProvider>
)
}
`;
}
private buildFormDefinition(data: ModuleLayoutConfig) {
return `
export const validationSchema = ${source(this.createValidationSchema(data))};
export type FormValues = z.infer<typeof validationSchema>;
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => 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();
@@ -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 ? '<DataTable />' : ''));
return file.toString();
}
private buildComponent(tableSource: string) {
return `
const ListPage: React.FC = () => {
return (
<React.Fragment>
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
<Container>
${tableSource}
</Container>
</PermissionPageGate>
</React.Fragment>
);
};
export default ListPage;
`;
}
}
export default new ListPageSourceBuilder();
@@ -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<ModuleFormContainer, `sheet-${string}`> {
return container.startsWith('sheet-');
}
private getSheetSide(container: Extract<ModuleFormContainer, `sheet-${string}`>): 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<ModuleFormContainer, `dialog-${string}`>): 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.trigger}>
<${config.content} ${config.contentProps}>
<${config.header}>
<${config.title}>{title}</${config.title}>
<${config.description} className="sr-only"></${config.description}>
</${config.header}>
<${config.body}${bodyProps}>
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</${config.body}>
<${config.footer}>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<${config.close} asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
</Button>
</${config.close}>
</${config.footer}>
</${config.content}>
</${config.root}>
);
};
`;
}
}
export default new ManageTriggerSourceBuilder();
@@ -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();
@@ -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();
@@ -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<ModuleFormContainer, `sheet-${string}`> {
return container.startsWith('sheet-');
}
private getSheetSide(
container: Extract<ModuleFormContainer, `sheet-${string}`>
): '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<ModuleFormContainer, `dialog-${string}`>
): '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 (
<Dialog open={dialog.isOpen} onOpenChange={dialog.toggle}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent variant="${dialogSize}">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody>
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</DialogBody>
<DialogFooter>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<DialogClose asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
`;
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 (
<Sheet open={sheet.isOpen} onOpenChange={sheet.toggle}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent side="${side}" className="max-h-dvh">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription className="sr-only"></SheetDescription>
</SheetHeader>
<SheetBody className="overflow-y-auto">
{isLoading ? (
<div className="grid grid-cols-2 gap-5">
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
<Skeleton className="h-15" />
</div>
) : (
<MainForm
initialValues={data ? prepareInitialValues(data) : initialValues}
onSubmit={submit}
/>
)}
</SheetBody>
<SheetFooter>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
{intl.formatMessage({ id: 'STATE.SAVING' })}...
</span>
) : (
intl.formatMessage({ id: 'ACTION.SAVE' })
)}
</Button>
<SheetClose asChild>
<Button variant="outline">
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
`;
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
}
}
export default new FormDialogTemplateBuilder();
@@ -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 = '<DataTable />';
}
const raw = `
${imports.join('\n')}
\n
const ListPage: React.FC = () => {
return (
<React.Fragment>
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
<Container>
${table}
</Container>
</PermissionPageGate>
</React.Fragment>
);
};
export default ListPage;
`;
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
}
}
export default new ListPageTemplateBuilder();
@@ -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();
@@ -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();
@@ -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<string>();
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');
}
}
@@ -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)
};
}