TASKS-1823 WebManager module
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user