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