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
@@ -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')
}
}