TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -26,6 +26,9 @@ export async function getDatabaseTables(): Promise<string[]> {
'pg_index',
'pg_namespace',
'pg_tables',
'web_components',
'web_pages',
'web_settings',
])
.whereRaw(`table_name NOT LIKE 'pg_%'`)
.whereRaw(`table_name NOT LIKE 'sql_%'`)
@@ -0,0 +1,389 @@
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleAsset,
ModuleGeneratorConfig,
ModuleTableColumn,
ModuleTableConfig,
} from '#services/module_generator/types'
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 ?? { columns: [] }
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()
@@ -0,0 +1,670 @@
import {
toComboboxGroups,
toFlatOptions,
toMultiSelectOptions,
} from '#services/module_generator/builders/shared/field_option_groups'
import {
indentSource,
source,
SourceFile,
} from '#services/module_generator/builders/shared/source_file'
import {
ModuleFieldType,
ModuleGridCell,
ModuleLayoutConfig,
} from '#services/module_generator/types'
// Every component from '#shared/forms/composed' the builder is able to emit. Only the ones
// actually present in the generated source are imported, so the module passes `noUnusedLocals`.
const COMPOSED_COMPONENTS = [
'FormElement',
'FormGrid',
'GridElement',
'InputCalendar',
'InputCheckboxGroup',
'InputColorPicker',
'InputCombobox',
'InputCreatableCombobox',
'InputDate',
'InputDatePicker',
'InputDatePickerRange',
'InputDropzone',
'InputFile',
'InputMultiSelect',
'InputNumberCounter',
'InputPhone',
'InputRadioGroup',
'InputSelect',
'InputSwitch',
'InputText',
'InputTextarea',
'InputTime',
'InputTiptapEditor',
]
class FormSourceBuilder {
buildComponent(data: ModuleLayoutConfig) {
const file = new SourceFile()
const section = this.buildMainFormComponent(data)
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 { ${this.collectComposedComponents(section).join(', ')} } from '#shared/forms/composed';`,
"import { FORM_ID } from './constants';",
"import { FormValues, SubmitFunc, validationSchema } from './form';",
])
file.addSection(section)
return file.toString()
}
private collectComposedComponents(section: string): string[] {
return COMPOSED_COMPONENTS.filter((name) => new RegExp(`<${name}\\b`).test(section))
}
buildDefinition(data: ModuleLayoutConfig) {
const file = new SourceFile()
file.addImports([
"import { UseFormReturn } from 'react-hook-form';",
"import z from 'zod';",
"import { Entity } from '../../../entities/Entity';",
])
if (data.grid.find((item) => item.body?.type === ModuleFieldType.PHONE)) {
file.addImport("import { e164ToMask } from '#shared/lib/phoneNumber';")
}
file.addSection(this.buildFormDefinition(data))
return file.toString()
}
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}>
<FormElement
id={FORM_ID}
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
>
<FormGrid columns={${data.cols}}>
${indentSource(this.createFieldsSource(data), 16)}
</FormGrid>
</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 is string => str !== null)
.map(source)
.join('\n')
}
private createInitialValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).created)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{ id: null, ${values} }`
}
private createValidationSchema(formConfig: ModuleLayoutConfig): string {
const schemaStr = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
required: item.body?.required ?? false,
}))
.map(this.mapTypeToZod)
.filter((str) => str.trim().length !== 0)
.join(',')
return `
z.object({
id: z.number().nullable(),
${schemaStr}
})
`
}
private createPreparedValues(formConfig: ModuleLayoutConfig): string {
const values = formConfig.grid
.filter((item) => item.isEmpty === false)
.map((item) => ({
name: item.body?.dbColumn || '',
type: item.body?.type || ModuleFieldType.TEXT,
}))
.map((item) => this.mapTypeToInitialValue(item).prepared)
.filter((str) => str.trim().length !== 0)
.join(',')
return `{id: data?.id ?? null, ${values}}`
}
private mapTypeToInitialValue({ name, type }: { name: string; type: ModuleFieldType }): {
created: string
prepared: string
} {
if (name === 'id') {
return {
created: `id: null`,
prepared: `id: data?.id ?? null`,
}
}
switch (type) {
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.TEXT:
case ModuleFieldType.EMAIL:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.TEXTAREA:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name}?.toString() ?? ''`,
}
}
case ModuleFieldType.PHONE: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? e164ToMask(data?.${name}) : ''`,
}
}
case ModuleFieldType.MULTISELECT: {
return {
created: `${name}: []`,
prepared: `${name}: data?.${name} ?? []`,
}
}
case ModuleFieldType.FILE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.DROPZONE: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ?? null`,
}
}
case ModuleFieldType.SWITCH: {
return {
created: `${name}: false`,
prepared: `${name}: data?.${name} ?? false`,
}
}
case ModuleFieldType.TIME_INPUT_ISO:
case ModuleFieldType.TIME_INPUT_HMS: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_INPUT: {
return {
created: `${name}: ''`,
prepared: `${name}: data?.${name} ? data?.${name} : ''`,
}
}
case ModuleFieldType.DATE_PICKER_RANGE: {
return {
created: `${name}: null`,
prepared: `${name}: null`,
}
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return {
created: `${name}: null`,
prepared: `${name}: data?.${name} ? new Date(data?.${name}) : null`,
}
}
default: {
return {
created: '',
prepared: '',
}
}
}
}
private mapTypeToZod({
name,
required,
type,
}: {
name: string
type: ModuleFieldType
required: boolean
}): string {
const withOptional = (schema: string) => {
return required ? schema : `${schema}.optional().nullable()`
}
const withOptionalString = (schema: string) => {
return required ? `${schema}.min(1)` : `${schema}.optional().nullable()`
}
if (name === 'id') {
return `id: z.number().nullable()`
}
if (name === 'uuid') {
return `uuid: z.coerce.string().nullable()`
}
switch (type) {
case ModuleFieldType.TEXT:
case ModuleFieldType.TEXTAREA: {
return `${name}: ${withOptionalString('z.coerce.string()')}`
}
case ModuleFieldType.CHECKBOX:
case ModuleFieldType.RADIO:
case ModuleFieldType.NUMBER:
case ModuleFieldType.PASSWORD:
case ModuleFieldType.SELECT:
case ModuleFieldType.COMBOBOX:
case ModuleFieldType.CREATABLE_COMBOBOX:
case ModuleFieldType.PHONE:
case ModuleFieldType.EDITOR:
case ModuleFieldType.COLOR_PICKER:
case ModuleFieldType.NUMBER_COUNTER: {
return `${name}: ${withOptional('z.coerce.string()')}`
}
case ModuleFieldType.EMAIL: {
const schema = required
? 'z.coerce.string().email()'
: 'z.coerce.string().email().or(z.literal(""))'
return `${name}: ${schema}`
}
case ModuleFieldType.MULTISELECT: {
const schema = required ? 'z.array(z.number()).min(1)' : 'z.array(z.number())'
return `${name}: ${schema}`
}
case ModuleFieldType.FILE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.DROPZONE: {
return `${name}: ${withOptional('z.union([z.null(), z.string(), z.instanceof(File)])')}`
}
case ModuleFieldType.SWITCH: {
return `${name}: z.boolean()`
}
case ModuleFieldType.DATE_PICKER_RANGE: {
const schema = withOptional(`
z.object({
from: z.date().optional().nullable(),
to: z.date().optional().nullable(),
})
`)
return `${name}: ${schema}`
}
case ModuleFieldType.DATE_PICKER_SINGLE:
case ModuleFieldType.CALENDAR: {
return `${name}: ${withOptional('z.date()')}`
}
case ModuleFieldType.DATE_INPUT: {
const schema = withOptional('z.string().datetime()')
return `${name}: ${schema}`
}
case ModuleFieldType.TIME_INPUT_ISO: {
return `${name}: ${withOptional('z.string().datetime()')}`
}
case ModuleFieldType.TIME_INPUT_HMS: {
return `${name}: ${withOptional('z.string().time()')}`
}
default:
return ``
}
}
private mapGridCellToComponent(data: ModuleGridCell) {
const colSpan = data.colSpan
if (!data.body || colSpan === 0) {
return null
}
const { label, options = [], required, type, placeholder, dbColumn: name } = data.body
switch (type) {
case ModuleFieldType.CHECKBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCheckboxGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.RADIO:
return `
<GridElement colSpan={${colSpan}}>
<InputRadioGroup
name="${name}"
label="${label}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXT:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="text"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PHONE:
return `
<GridElement colSpan={${colSpan}}>
<InputPhone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EMAIL:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="email"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="number"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.PASSWORD:
return `
<GridElement colSpan={${colSpan}}>
<InputText
name="${name}"
type="password"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TEXTAREA:
return `
<GridElement colSpan={${colSpan}}>
<InputTextarea
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.FILE:
return `
<GridElement colSpan={${colSpan}}>
<InputFile
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DROPZONE:
return `
<GridElement colSpan={${colSpan}}>
<InputDropzone
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.MULTISELECT:
return `
<GridElement colSpan={${colSpan}}>
<InputMultiSelect
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toMultiSelectOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
groups={${JSON.stringify(toComboboxGroups(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CREATABLE_COMBOBOX:
return `
<GridElement colSpan={${colSpan}}>
<InputCreatableCombobox
name="${name}"
label="${label}"
placeholder="${placeholder}"
options={${JSON.stringify(toFlatOptions(options))}}
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.SWITCH:
return `
<GridElement colSpan={${colSpan}}>
<InputSwitch
name="${name}"
label="${label}"
required={${required}}
wrapperClassName="flex-col items-start"
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_RANGE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePickerRange
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_PICKER_SINGLE:
return `
<GridElement colSpan={${colSpan}}>
<InputDatePicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.CALENDAR:
return `
<GridElement colSpan={${colSpan}}>
<InputCalendar
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.DATE_INPUT:
return `
<GridElement colSpan={${colSpan}}>
<InputDate
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_ISO:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="ISO"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.TIME_INPUT_HMS:
return `
<GridElement colSpan={${colSpan}}>
<InputTime
name="${name}"
format="HMS"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.EDITOR:
return `
<GridElement colSpan={${colSpan}}>
<InputTiptapEditor
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.COLOR_PICKER:
return `
<GridElement colSpan={${colSpan}}>
<InputColorPicker
name="${name}"
label="${label}"
required={${required}}
/>
</GridElement>
`
case ModuleFieldType.NUMBER_COUNTER:
return `
<GridElement colSpan={${colSpan}}>
<InputNumberCounter
name="${name}"
label="${label}"
placeholder="${placeholder}"
required={${required}}
/>
</GridElement>
`
default:
return `<GridElement colSpan={${colSpan}} />`
}
}
}
export default new FormSourceBuilder()
@@ -0,0 +1,42 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ListPageSourceBuilder {
build(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 { SourceFile } from '#services/module_generator/builders/shared/source_file'
import { ModuleFormContainer } from '#services/module_generator/types'
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 '#services/module_generator/builders/shared/source_file'
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,36 @@
import { SourceFile } from '#services/module_generator/builders/shared/source_file'
class ModuleConfigSourceBuilder {
build(
moduleId: string,
options?: {
includeMenu: boolean
}
) {
const file = new SourceFile()
file.addImports([
"import { AppModuleConfig } from '#app/services/modules/types';",
"import messages from './messages';",
"import routes from './routing';",
])
if (options?.includeMenu) file.addImport("import menu from './menu';")
file.addSection(`
export default {
id: '${moduleId}',
area: 'business',
routeScope: 'protected',
order: 100,
routes,
menu: ${options?.includeMenu ? 'menu' : '() => null'},
messages,
} satisfies AppModuleConfig;
`)
return file.toString()
}
}
export default new ModuleConfigSourceBuilder()
@@ -0,0 +1,50 @@
import { ModuleFieldOption } from '#services/module_generator/types'
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,65 @@
type SourceChunk = string | null | false | undefined
const EMPTY_JSX_TEXT_EXPRESSION_REGEX = /\{\s*'\s*'\s*\}/g
function normalizeSource(value: string): string {
const trimmed = value.replace(/^\n/, '').replace(/\n\s*$/, '')
const lines = trimmed.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')
}
}
@@ -0,0 +1,29 @@
export function mapDBTypeToTs(type: string): string {
if (type.includes('int')) return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'DateTime'
return 'string'
}
export function mapDBTypeToValidator(type: string): string {
if (
type.includes('int') ||
type === 'numeric' ||
type === 'decimal' ||
type === 'real' ||
type === 'double precision'
) {
return 'number'
}
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'date'
return 'string'
}
export function mapDBTypeToFrontend(type: string): string {
if (type.includes('int') || type === 'numeric' || type === 'bigint') return 'number'
if (type === 'boolean') return 'boolean'
if (type.includes('timestamp') || type === 'date') return 'IsoDateString'
return 'string'
}
@@ -0,0 +1,73 @@
import { join } from 'node:path'
import { writeBackendModule } from '#services/module_generator/writers/backend_module_writer'
import DataTableSourceBuilder from '#services/module_generator/builders/data_table_source_builder'
import FormSourceBuilder from '#services/module_generator/builders/form_source_builder'
import ListPageSourceBuilder from '#services/module_generator/builders/list_page_source_builder'
import ManageTriggerSourceBuilder from '#services/module_generator/builders/manage_trigger_source_builder'
import MenuSourceBuilder from '#services/module_generator/builders/menu_source_builder'
import ModuleConfigSourceBuilder from '#services/module_generator/builders/module_config_source_builder'
import { writeFrontendModule } from '#services/module_generator/writers/frontend_module_writer'
import { buildModuleContext, ModuleContext } from '#services/module_generator/module_context'
import { capitalize } from '#services/module_generator/naming'
import { ModuleAsset, ModuleGeneratorConfig } from '#services/module_generator/types'
/**
* Generates a full module from its table schema and generator config:
* frontend module in the sibling admin workspace, backend assets under
* modules/<Module>. Returns the generated module's name.
*/
export async function generateModule(config: ModuleGeneratorConfig): Promise<string> {
const appBase = process.cwd()
const context = await buildModuleContext(config.table)
writeFrontendAssets(config, context, appBase)
await writeBackendModule({
name: config.name,
table: config.table,
generate: config.generate,
appBase,
context,
})
return capitalize(config.name)
}
// Template tree first (hbs rendered, the rest copied verbatim), then builder
// output for the selected assets. Files whose asset is not selected keep the
// scaffold defaults from the template tree.
function writeFrontendAssets(
config: ModuleGeneratorConfig,
context: ModuleContext,
appBase: string
) {
const { name, generate, form, datatable } = config
const shouldGenerateForm = generate.includes(ModuleAsset.FORM) && !!form
const shouldGenerateDataTable = generate.includes(ModuleAsset.DATATABLE) && !!datatable
writeFrontendModule({
templateDir: join(appBase, 'app', 'templates', 'frontend', 'module'),
outDir: join(appBase, '..', 'admin', 'src', 'modules', capitalize(name)),
context: { name, fields: context.frontendFields },
overrides: {
'view/forms/MainForm/Component.tsx': shouldGenerateForm
? FormSourceBuilder.buildComponent(form)
: null,
'view/forms/MainForm/form.ts': shouldGenerateForm
? FormSourceBuilder.buildDefinition(form)
: null,
'view/widgets/ManageTrigger.tsx': shouldGenerateForm
? ManageTriggerSourceBuilder.build(form.size)
: null,
'view/widgets/DataTable.tsx': shouldGenerateDataTable
? DataTableSourceBuilder.build(config)
: null,
'pages/ListPage/index.tsx': ListPageSourceBuilder.build({
addTable: shouldGenerateDataTable,
}),
'config/menu.ts': shouldGenerateDataTable ? MenuSourceBuilder.build() : null,
'config/module.ts': ModuleConfigSourceBuilder.build(name, {
includeMenu: shouldGenerateDataTable,
}),
},
})
}
@@ -0,0 +1,135 @@
import { getColumnsForTable, getForeignKeysReferencing } from '#services/database_inspector'
import {
mapDBTypeToFrontend,
mapDBTypeToTs,
mapDBTypeToValidator,
} from '#services/module_generator/db_types'
import { camelCase, capitalize } from '#services/module_generator/naming'
export interface EnhancedColumn {
name: string
camelCase: string
type: string
isPrimary: boolean
isPassword: boolean
isEmail: boolean
isDateTime: boolean
isSpecial: boolean
isRequired: boolean
isNullable: boolean
validatorType: string
needsTrim: boolean
}
export interface TestField {
camelCase: string
sampleValue: string | number | boolean
column_name: string
}
export interface ModuleContext {
hasPassword: boolean
enhancedColumns: EnhancedColumn[]
createFields: TestField[]
updateFields: TestField[]
insertJson: string
frontendFields: Array<{ name: string; type: string }>
relationships: { imports: string[]; code: string[] }
}
/** Derives everything the templates and builders need from the table schema. */
export async function buildModuleContext(table: string): Promise<ModuleContext> {
const columns = await getColumnsForTable(table)
const enhancedColumns = columns.map((col): EnhancedColumn => {
const colName = col.column_name
const type = mapDBTypeToTs(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : '')
return {
name: colName,
camelCase: camelCase(colName),
type,
isPrimary: colName === 'id',
isPassword: colName === 'password',
isEmail: colName === 'email',
isDateTime: ['created_at', 'updated_at'].includes(colName),
isSpecial: ['created_at', 'updated_at', 'deleted_at'].includes(colName),
isRequired: col.is_nullable === 'NO',
isNullable: col.is_nullable === 'YES',
validatorType: mapDBTypeToValidator(col.data_type),
needsTrim:
mapDBTypeToValidator(col.data_type) === 'string' &&
colName !== 'email' &&
colName !== 'password',
}
})
const sampleFor = (col: EnhancedColumn) => {
const t = col.validatorType
if (t === 'number') return 1
if (t === 'boolean') return false
if (t === 'date') return `'${new Date().toISOString()}'`
return `'sample_${col.camelCase}'`
}
const toTestField = (col: EnhancedColumn): TestField => ({
camelCase: col.camelCase,
sampleValue: sampleFor(col),
column_name: col.name,
})
const createFields = enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial).map(toTestField)
const updateFields = enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime).map(toTestField)
// minimal insert json for show/update/destroy fixtures
const insertJson = `{
${createFields.map((f) => ` ${f.column_name}: ${f.sampleValue}`).join(',\n')}
}`
const frontendFields = columns.map((col) => ({
name: col.column_name,
type: mapDBTypeToFrontend(col.data_type) + (col.is_nullable === 'YES' ? ' | null' : ''),
}))
return {
hasPassword: columns.some((col) => col.column_name === 'password'),
enhancedColumns,
createFields,
updateFields,
insertJson,
frontendFields,
relationships: await buildRelationships(table),
}
}
/** Lucid relationship decorators inferred from the `<table>_id` column convention. */
async function buildRelationships(table: string) {
const lines: string[] = []
const imports: Set<string> = new Set()
const columns = await getColumnsForTable(table)
const references = await getForeignKeysReferencing(table)
for (const col of columns) {
if (col.column_name.endsWith('_id')) {
const refTable = col.column_name.replace('_id', '')
const refModel = capitalize(refTable)
imports.add('belongsTo')
lines.push(
` @belongsTo(() => ${refModel})\n declare ${refTable}: BelongsTo<typeof ${refModel}>`
)
}
}
for (const ref of references) {
const relModel = capitalize(ref.table_name)
imports.add('hasMany')
lines.push(
` @hasMany(() => ${relModel})\n declare ${ref.table_name}: HasMany<typeof ${relModel}>`
)
}
return {
imports: Array.from(imports),
code: lines,
}
}
@@ -0,0 +1,27 @@
import app from '@adonisjs/core/services/app'
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function camelCase(str: string) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
}
export function pascalCase(str: string) {
return capitalize(camelCase(str))
}
// Naive singularization used for generated file names ("posts" -> "post"):
// simply drops the trailing character.
export function singularize(str: string) {
return str.slice(0, str.length - 1)
}
export function tableToModelName(tableName: string): string {
return app.generators.modelName(tableName)
}
export function tableToModelSchemaName(tableName: string): string {
return app.generators.modelName(tableName) + 'Schema'
}
@@ -0,0 +1,106 @@
/* 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
COLOR_PICKER = 'colorPicker', // InputColorPicker
NUMBER_COUNTER = 'numberCounter', // InputNumberCounter
COMBOBOX = 'comboboxSelect', // InputCombobox
CREATABLE_COMBOBOX = 'creatableCombobox', // InputCreatableCombobox
MULTISELECT = 'multiselect', // InputMultiSelect
}
/* 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
}
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 ModuleGeneratorConfig {
name: string
table: string
generate: ModuleAsset[]
form: ModuleLayoutConfig | null
datatable: ModuleTableConfig | null
}
@@ -0,0 +1,235 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { modelToSchema } from '#services/swagger_model'
import { ModuleContext } from '#services/module_generator/module_context'
import {
capitalize,
pascalCase,
singularize,
tableToModelSchemaName,
tableToModelName,
} from '#services/module_generator/naming'
import { renderTemplate } from '#services/module_generator/writers/template_renderer'
import { ModuleAsset } from '#services/module_generator/types'
export interface WriteBackendModuleOptions {
name: string
table: string
generate: ModuleAsset[]
appBase: string
context: ModuleContext
}
/** Renders the selected backend assets from app/templates/backend into modules/<Module>. */
export async function writeBackendModule(options: WriteBackendModuleOptions) {
const { name, table, generate, appBase, context } = options
const { enhancedColumns, createFields, updateFields, insertJson, relationships } = context
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const modelClass = capitalize(table)
const snake = singularize(table)
const paths = {
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${tableToModelName(table)}.ts`),
// model: join(modulePath, 'model', `${snake}.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(appBase, 'tests', 'modules', moduleName, `${snake}.spec.ts`),
testApi: join(appBase, 'tests', 'modules', moduleName, `${snake}.api.spec.ts`),
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
if (generate.includes(ModuleAsset.MODEL)) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: pascalCase(name),
capName: capitalize(name),
className: modelClass,
imports: relationships.imports.join(', '),
columns: enhancedColumns,
relationships: relationships.code,
hasPassword: context.hasPassword,
tableName: table,
modelSchemaName: tableToModelSchemaName(table),
})
writeFileSync(paths.model, content)
}
if (generate.includes(ModuleAsset.CONTROLLER)) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const ctrlSpec = renderTemplate(join(backendTplDir, 'tests', 'controller.spec.hbs'), {
pascalCaseName: pascalCase(name),
snakeCaseName: snake,
tableName: table,
createFields,
updateFields,
insertJson,
})
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
if (generate.includes(ModuleAsset.ROUTES)) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
if (generate.includes(ModuleAsset.VALIDATOR)) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
if (generate.includes(ModuleAsset.POLICY)) {
writePolicy(paths, backendTplDir, name, moduleName, modelClass)
}
if (generate.includes(ModuleAsset.API)) {
const { default: genModel } = await import(paths.model)
const apiCtx = {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
writeFileSync(
paths.apiController,
renderTemplate(join(apiTplDir, 'controller.hbs'), apiCtx),
'utf8'
)
const apiSpec = renderTemplate(join(backendTplDir, 'tests', 'api.spec.hbs'), {
snakeCaseName: snake,
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
writeFileSync(paths.apiRoutes, renderTemplate(join(apiTplDir, 'routes.hbs'), apiCtx), 'utf8')
}
}
/** Policy file + registration in app/policies/main.ts + module permissions config. */
function writePolicy(
paths: { policy: string; policiesMain: string; permissionsConfig: string },
backendTplDir: string,
name: string,
moduleName: string,
modelClass: string
) {
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(join(backendTplDir, 'policy.hbs'), {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
@@ -0,0 +1,59 @@
import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import handlebars from 'handlebars'
const HBS_EXTENSION = '.hbs'
export interface WriteFrontendModuleOptions {
/** Root of the module template tree (app/templates/frontend/module). */
templateDir: string
/** Module output directory inside the admin workspace. */
outDir: string
/** Context for rendering the `*.hbs` files in the tree. */
context: Record<string, unknown>
/**
* Generated sources keyed by module-relative path. They are written after the
* template tree, replacing the scaffold defaults. A null value means the asset
* was not selected — the scaffold default from the tree is kept as-is.
*/
overrides: Record<string, string | null>
}
/**
* Materializes a frontend module from the template tree: `*.hbs` files are
* rendered with `context` and written without the extension, every other file
* is copied verbatim, and directory nesting is preserved. Builder-generated
* sources in `overrides` are then written on top.
*/
export function writeFrontendModule(options: WriteFrontendModuleOptions) {
renderTree(options.templateDir, options.outDir, options.context)
for (const [relativePath, content] of Object.entries(options.overrides)) {
if (content === null) continue
const target = join(options.outDir, relativePath)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content, 'utf8')
}
}
function renderTree(dir: string, outDir: string, context: Record<string, unknown>) {
mkdirSync(outDir, { recursive: true })
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const sourcePath = join(dir, entry.name)
if (entry.isDirectory()) {
renderTree(sourcePath, join(outDir, entry.name), context)
continue
}
if (entry.name.endsWith(HBS_EXTENSION)) {
// noEscape: the templates emit TypeScript, HTML entities would corrupt it.
const template = handlebars.compile(readFileSync(sourcePath, 'utf8'), { noEscape: true })
writeFileSync(join(outDir, entry.name.slice(0, -HBS_EXTENSION.length)), template(context))
} else {
copyFileSync(sourcePath, join(outDir, entry.name))
}
}
}
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import handlebars from 'handlebars'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
export function renderTemplate(templatePath: string, context: Record<string, unknown>): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}