Feature/template builder rework
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
|
||||
import { ModuleGeneratorConfig, ModuleTableColumn, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
import { indentSource, SourceFile, source } from './shared/SourceFile';
|
||||
|
||||
class DataTableSourceBuilder {
|
||||
build(data: ModuleGeneratorConfig) {
|
||||
const file = new SourceFile();
|
||||
|
||||
this.addImports(file, data);
|
||||
file.addSection(this.buildColumnFilterPropsInterface());
|
||||
file.addSection(this.buildDataTableComponent(data));
|
||||
file.addSection(this.buildToolbarComponent(data));
|
||||
file.addSection(this.buildColumnInputFilterComponent());
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
private addImports(file: SourceFile, data: ModuleGeneratorConfig) {
|
||||
const shouldAddEditForm = this.shouldAddEditForm(data.generate);
|
||||
const icons = ['LoaderCircleIcon', 'SearchIcon', 'Settings2'];
|
||||
const metaImports = ['MODULE_PERMISSION_DELETE'];
|
||||
|
||||
if (shouldAddEditForm) {
|
||||
icons.push('SquarePen');
|
||||
metaImports.unshift('MODULE_NAME_LABEL', 'MODULE_PERMISSION_CREATE');
|
||||
metaImports.push('MODULE_PERMISSION_UPDATE');
|
||||
}
|
||||
|
||||
file.addImports([
|
||||
"import React from 'react';",
|
||||
`
|
||||
import {
|
||||
Column,
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
`,
|
||||
`import { ${icons.join(', ')} } from 'lucide-react';`,
|
||||
"import { useIntl } from 'react-intl';",
|
||||
"import { Badge } from '#shared/ui/Badge';",
|
||||
"import { CopyButton } from '#shared/ui/CopyButton';",
|
||||
"import { Button } from '#shared/ui/Button';",
|
||||
`
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTable,
|
||||
CardTitle,
|
||||
CardToolbar,
|
||||
} from '#shared/ui/Card';
|
||||
`,
|
||||
"import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid';",
|
||||
"import { Input, InputWrapper } from '#shared/ui/Input';",
|
||||
"import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';",
|
||||
"import { Skeleton } from '#shared/ui/Skeleton';",
|
||||
"import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';",
|
||||
"import { useDebounceSearch } from '@crux-kit/hooks';",
|
||||
"import { PermissionGate } from '#core/Permission';",
|
||||
"import { useGetList } from '../../api/list';",
|
||||
`import { ${metaImports.join(', ')} } from '../../config/meta';`,
|
||||
"import { Entity } from '../../entities/Entity';",
|
||||
"import { DeleteTrigger } from './DeleteTrigger';"
|
||||
]);
|
||||
|
||||
if (shouldAddEditForm) {
|
||||
file.addImport("import { ManageTrigger } from './ManageTrigger';");
|
||||
}
|
||||
}
|
||||
|
||||
private buildColumnFilterPropsInterface() {
|
||||
return `
|
||||
interface IColumnFilterProps<TData, TValue> {
|
||||
column: Column<TData, TValue>
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
private buildDataTableComponent(data: ModuleGeneratorConfig) {
|
||||
return `
|
||||
export const DataTable = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [search, setSearch] = React.useState<string>('');
|
||||
|
||||
const { handleChange } = useDebounceSearch({
|
||||
minQueryNumber: 3,
|
||||
delay: 400,
|
||||
initialQuery: search,
|
||||
onTimeOut: setSearch,
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
|
||||
mapTableStateToListQueryParams(pagination, sorting, search)
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<Entity>[]>(
|
||||
() => [
|
||||
${indentSource(this.createColumnsSource(data), 12)}
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
autoResetPageIndex: false,
|
||||
enableMultiSort: false,
|
||||
data: list,
|
||||
manualPagination: true,
|
||||
pageCount: pagesCount,
|
||||
getRowId: (row) => String(row.id),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={total || 0}
|
||||
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
|
||||
loadingMode="skeleton"
|
||||
isLoading={isLoading}
|
||||
tableLayout={{
|
||||
columnsDraggable: false,
|
||||
columnsResizable: false,
|
||||
width: 'auto',
|
||||
columnsPinnable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
cellBorder: true,
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<InputWrapper>
|
||||
{search.length > 0 && isFetching ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4" />
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</CardTitle>
|
||||
<Toolbar />
|
||||
</CardHeader>
|
||||
<CardTable>
|
||||
<ScrollArea>
|
||||
<DataGridTable />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</CardTable>
|
||||
<CardFooter>
|
||||
<DataGridPagination />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</DataGrid>
|
||||
)
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
private buildToolbarComponent(data: ModuleGeneratorConfig) {
|
||||
return `
|
||||
const Toolbar = () => {
|
||||
const { table } = useDataGrid();
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<CardToolbar>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
${this.shouldAddEditForm(data.generate) ? indentSource(this.buildCreateButton(), 14) : ''}
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Settings2 />
|
||||
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardToolbar>
|
||||
);
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
private buildColumnInputFilterComponent() {
|
||||
return `
|
||||
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={intl.formatMessage({ id: 'FILTER' })}
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className="h-9 w-full max-w-40"
|
||||
/>
|
||||
);
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
private buildCreateButton() {
|
||||
return `
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
|
||||
<ManageTrigger
|
||||
id={null}
|
||||
title={intl.formatMessage(
|
||||
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
|
||||
{ entity: MODULE_NAME_LABEL }
|
||||
)}
|
||||
>
|
||||
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
`;
|
||||
}
|
||||
|
||||
private buildEditButton() {
|
||||
return `
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
|
||||
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
|
||||
<Button variant="secondary" size="sm" shape="default" mode="icon">
|
||||
<SquarePen />
|
||||
</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
`;
|
||||
}
|
||||
|
||||
private shouldAddEditForm(assets: ModuleAsset[]): boolean {
|
||||
return assets.includes(ModuleAsset.FORM);
|
||||
}
|
||||
|
||||
private createColumnsSource(ctx: ModuleGeneratorConfig) {
|
||||
const data: ModuleTableConfig = ctx.datatable;
|
||||
const columns: string[] = [];
|
||||
|
||||
data.columns
|
||||
.filter((column) => column.hidden === false)
|
||||
.forEach((column) => {
|
||||
const name = column.value;
|
||||
|
||||
if (column.isActionColumn) {
|
||||
columns.push(this.createActionColumn(column, ctx));
|
||||
return;
|
||||
}
|
||||
if (name === 'id' || name === 'uuid') {
|
||||
columns.push(this.createIdColumn(column));
|
||||
return;
|
||||
}
|
||||
|
||||
columns.push(this.createBaseColumn(column));
|
||||
});
|
||||
|
||||
return columns.map(source).join(',\n');
|
||||
}
|
||||
|
||||
private createBaseColumn(data: ModuleTableColumn) {
|
||||
const enableSorting = data.innerProps.sort ? 'true' : 'false';
|
||||
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
accessorFn: (row) => row.${data.value},
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="${data.label}"
|
||||
filter={<ColumnInputFilter column={column} />}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ${source(this.createCellContent(data))},
|
||||
enableSorting: ${enableSorting},
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private createIdColumn(data: ModuleTableColumn) {
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
|
||||
cell: ${source(this.createCellContent(data))},
|
||||
enableSorting: true,
|
||||
size: 80,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) {
|
||||
const editButtonSource = this.shouldAddEditForm(ctx.generate) ? this.buildEditButton() : '';
|
||||
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
header: '${data.label}',
|
||||
cell: (info) => {
|
||||
const data = info.row.original;
|
||||
const id = data.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
${editButtonSource ? indentSource(editButtonSource, 14) : ''}
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
|
||||
<DeleteTrigger id={id} deleteTarget={id} />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
meta: {
|
||||
headerClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private createCellContent(data: ModuleTableColumn) {
|
||||
if (data.innerProps.copy) {
|
||||
return `
|
||||
(info) => {
|
||||
const val = String(info.getValue());
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="line-clamp-1">{val}</code>
|
||||
<CopyButton copyText={val} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (data.innerProps.badge) {
|
||||
return `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
|
||||
}
|
||||
|
||||
return '(info) => info.getValue()';
|
||||
}
|
||||
}
|
||||
|
||||
export default new DataTableSourceBuilder();
|
||||
@@ -1,361 +0,0 @@
|
||||
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
|
||||
import { ModuleGeneratorConfig, ModuleTableColumn, ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
|
||||
class DataTableTemplateBuilder {
|
||||
build(data: ModuleGeneratorConfig) {
|
||||
const FormButton = `
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
|
||||
<ManageTrigger
|
||||
id={null}
|
||||
title={intl.formatMessage(
|
||||
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
|
||||
{ entity: MODULE_NAME_LABEL }
|
||||
)}
|
||||
>
|
||||
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
`;
|
||||
const raw = `
|
||||
import React from 'react';
|
||||
import {
|
||||
Column,
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Badge } from '#shared/ui/Badge';
|
||||
import { CopyButton } from '#shared/ui/CopyButton';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTable,
|
||||
CardTitle,
|
||||
CardToolbar,
|
||||
} from '#shared/ui/Card';
|
||||
import { DataGrid, useDataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable } from '#shared/widgets/DataGrid';
|
||||
import { Input, InputWrapper } from '#shared/ui/Input';
|
||||
import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
|
||||
import { Skeleton } from '#shared/ui/Skeleton';
|
||||
import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';
|
||||
import { useDebounceSearch } from '@crux-kit/hooks';
|
||||
import { PermissionGate } from '#core/Permission';
|
||||
import { useGetList } from '../../api/list';
|
||||
import {
|
||||
MODULE_NAME_LABEL,
|
||||
MODULE_PERMISSION_CREATE,
|
||||
MODULE_PERMISSION_DELETE,
|
||||
MODULE_PERMISSION_UPDATE,
|
||||
} from '../../config/meta';
|
||||
import { Entity } from '../../entities/Entity';
|
||||
import { DeleteTrigger } from './DeleteTrigger';
|
||||
${this.shouldAddEditForm(data.generate) ? "import { ManageTrigger } from './ManageTrigger';" : ''}
|
||||
|
||||
interface IColumnFilterProps<TData, TValue> {
|
||||
column: Column<TData, TValue>
|
||||
};
|
||||
|
||||
export const DataTable = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [search, setSearch] = React.useState<string>('');
|
||||
|
||||
const { handleChange } = useDebounceSearch({
|
||||
minQueryNumber: 3,
|
||||
delay: 400,
|
||||
initialQuery: search,
|
||||
onTimeOut: setSearch,
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
|
||||
mapTableStateToListQueryParams(pagination, sorting, search)
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<Entity>[]>(
|
||||
() => [${this.createColumnsHTML(data)}],
|
||||
[intl]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
autoResetPageIndex: false,
|
||||
enableMultiSort: false,
|
||||
data: list,
|
||||
manualPagination: true,
|
||||
pageCount: pagesCount,
|
||||
getRowId: (row) => String(row.id),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={total || 0}
|
||||
emptyMessage={intl.formatMessage({ id: 'TABLE.COMMON.NO_DATA_AVAILABLE' })}
|
||||
loadingMode="skeleton"
|
||||
isLoading={isLoading}
|
||||
tableLayout={{
|
||||
columnsDraggable: false,
|
||||
columnsResizable: false,
|
||||
width: 'auto',
|
||||
columnsPinnable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
cellBorder: true,
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<InputWrapper>
|
||||
{search.length > 0 && isFetching ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4" />
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</CardTitle>
|
||||
<Toolbar />
|
||||
</CardHeader>
|
||||
<CardTable>
|
||||
<ScrollArea>
|
||||
<DataGridTable />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</CardTable>
|
||||
<CardFooter>
|
||||
<DataGridPagination />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</DataGrid>
|
||||
)
|
||||
};
|
||||
|
||||
const Toolbar = () => {
|
||||
const { table } = useDataGrid();
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<CardToolbar>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
${this.shouldAddEditForm(data.generate) ? FormButton : ''}
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Settings2 />
|
||||
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={intl.formatMessage({ id: 'FILTER' })}
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className="h-9 w-full max-w-40"
|
||||
/>
|
||||
);
|
||||
};
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
|
||||
private shouldAddEditForm(assets: ModuleAsset[]): boolean {
|
||||
return assets.includes(ModuleAsset.FORM);
|
||||
}
|
||||
|
||||
private createColumnsHTML(ctx: ModuleGeneratorConfig) {
|
||||
const data: ModuleTableConfig = ctx.datatable;
|
||||
const columns: string[] = [];
|
||||
|
||||
data.columns
|
||||
.filter((column) => column.hidden === false)
|
||||
.forEach((column) => {
|
||||
const name = column.value;
|
||||
|
||||
if (column.isActionColumn) {
|
||||
columns.push(this.createActionColumn(column, ctx));
|
||||
return;
|
||||
}
|
||||
if (name === 'id' || name === 'uuid') {
|
||||
columns.push(this.createIdColumn(column));
|
||||
return;
|
||||
}
|
||||
|
||||
columns.push(this.createBaseColumn(column));
|
||||
});
|
||||
|
||||
return columns.join(',\n');
|
||||
}
|
||||
|
||||
private createBaseColumn(data: ModuleTableColumn) {
|
||||
const enableSorting = data.innerProps.sort ? 'true' : 'false';
|
||||
const baseCellContent = '(info) => info.getValue()';
|
||||
|
||||
let cellContent = baseCellContent;
|
||||
|
||||
if (data.innerProps.badge) {
|
||||
const cellContentWithBadge = `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
|
||||
cellContent = cellContentWithBadge;
|
||||
}
|
||||
|
||||
if (data.innerProps.copy) {
|
||||
const cellContentWithCopy = `
|
||||
(info) => {
|
||||
const val = String(info.getValue());
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="line-clamp-1">{val}</code>
|
||||
<CopyButton copyText={val} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
cellContent = cellContentWithCopy;
|
||||
}
|
||||
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
accessorFn: (row) => row.${data.value},
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="${data.label}"
|
||||
filter={<ColumnInputFilter column={column} />}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ${cellContent},
|
||||
enableSorting: ${enableSorting},
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private createIdColumn(data: ModuleTableColumn) {
|
||||
const baseCellContent = '(info) => info.getValue()';
|
||||
|
||||
let cellContent = baseCellContent;
|
||||
|
||||
if (data.innerProps.badge) {
|
||||
const cellContentWithBadge = `(info) => <Badge variant="primary" size="sm">{String(info.getValue())}</Badge>`;
|
||||
cellContent = cellContentWithBadge;
|
||||
}
|
||||
|
||||
if (data.innerProps.copy) {
|
||||
const cellContentWithCopy = `
|
||||
(info) => {
|
||||
const val = String(info.getValue());
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="line-clamp-1">{val}</code>
|
||||
<CopyButton copyText={val} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
cellContent = cellContentWithCopy;
|
||||
}
|
||||
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader title="${data.label}" column={column} />,
|
||||
cell: ${cellContent},
|
||||
enableSorting: true,
|
||||
size: 80,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private createActionColumn(data: ModuleTableColumn, ctx: ModuleGeneratorConfig) {
|
||||
const EditFormButton = `
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
|
||||
<ManageTrigger title={\`Edit \${MODULE_NAME_LABEL\}\`} id={id}>
|
||||
<Button variant="secondary" size="sm" shape="default" mode="icon">
|
||||
<SquarePen />
|
||||
</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
`;
|
||||
const DeleteRowButton = `
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
|
||||
<DeleteTrigger id={id} deleteTarget={id} />
|
||||
</PermissionGate>
|
||||
`;
|
||||
|
||||
return `
|
||||
{
|
||||
id: '${data.value}',
|
||||
header: '${data.label}',
|
||||
cell: (info) => {
|
||||
const data = info.row.original;
|
||||
const id = data.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
${this.shouldAddEditForm(ctx.generate) && EditFormButton}
|
||||
${DeleteRowButton}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
meta: {
|
||||
headerClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />,
|
||||
},
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DataTableTemplateBuilder();
|
||||
+74
-53
@@ -1,74 +1,95 @@
|
||||
import { ModuleFieldType } from '#devtools/entities/ModuleFieldType';
|
||||
import { ModuleGridCell, ModuleLayoutConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
import { indentSource, SourceFile, source } from './shared/SourceFile';
|
||||
|
||||
class FormTemplateBuilder {
|
||||
class FormSourceBuilder {
|
||||
buildComponent(data: ModuleLayoutConfig) {
|
||||
const raw = `
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form as FormProvider } from '#shared/ui/form';
|
||||
import * as Component from '#shared/forms/composed';
|
||||
import { FORM_ID } from './constants';
|
||||
import { FormValues, SubmitFunc, validationSchema } from './form';
|
||||
const file = new SourceFile();
|
||||
|
||||
export const MainForm: React.FC<{
|
||||
initialValues: FormValues;
|
||||
onSubmit: SubmitFunc;
|
||||
}> = ({ initialValues, onSubmit }) => {
|
||||
const form = useForm<FormValues>({
|
||||
values: initialValues,
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
file.addImports([
|
||||
"import React from 'react';",
|
||||
"import { useForm } from 'react-hook-form';",
|
||||
"import { zodResolver } from '@hookform/resolvers/zod';",
|
||||
"import { Form as FormProvider } from '#shared/ui/form';",
|
||||
"import * as Component from '#shared/forms/composed';",
|
||||
"import { FORM_ID } from './constants';",
|
||||
"import { FormValues, SubmitFunc, validationSchema } from './form';"
|
||||
]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Component.FormElement
|
||||
id={FORM_ID}
|
||||
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
|
||||
>
|
||||
<Component.FormGrid columns={${data.cols}}>
|
||||
${this.createFieldsHTML(data)}
|
||||
</Component.FormGrid>
|
||||
</Component.FormElement>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
`;
|
||||
file.addSection(this.buildMainFormComponent(data));
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
buildDefinition(data: ModuleLayoutConfig) {
|
||||
const raw = `
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import z from 'zod';
|
||||
import { e164ToMask } from '#shared/lib/phoneNumber';
|
||||
import { Entity } from '../../../entities/Entity';
|
||||
const file = new SourceFile();
|
||||
|
||||
export const validationSchema = ${this.createValidationSchema(data)};
|
||||
file.addImports([
|
||||
"import { UseFormReturn } from 'react-hook-form';",
|
||||
"import z from 'zod';",
|
||||
"import { Entity } from '../../../entities/Entity';"
|
||||
]);
|
||||
|
||||
export type FormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
|
||||
|
||||
export function prepareInitialValues(data: Entity): FormValues {
|
||||
if (data == null) return initialValues;
|
||||
|
||||
return ${this.createPreparedValues(data)}
|
||||
if (data.grid.find((item) => item.body?.type === ModuleFieldType.PHONE)) {
|
||||
file.addImport("import { e164ToMask } from '#shared/lib/phoneNumber';");
|
||||
}
|
||||
|
||||
export const initialValues: FormValues = ${this.createInitialValues(data)};
|
||||
`;
|
||||
file.addSection(this.buildFormDefinition(data));
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
private createFieldsHTML(formConfig: ModuleLayoutConfig): string {
|
||||
private buildMainFormComponent(data: ModuleLayoutConfig) {
|
||||
return `
|
||||
export const MainForm: React.FC<{
|
||||
initialValues: FormValues;
|
||||
onSubmit: SubmitFunc;
|
||||
}> = ({ initialValues, onSubmit }) => {
|
||||
const form = useForm<FormValues>({
|
||||
values: initialValues,
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Component.FormElement
|
||||
id={FORM_ID}
|
||||
onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
|
||||
>
|
||||
<Component.FormGrid columns={${data.cols}}>
|
||||
${indentSource(this.createFieldsSource(data), 16)}
|
||||
</Component.FormGrid>
|
||||
</Component.FormElement>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private buildFormDefinition(data: ModuleLayoutConfig) {
|
||||
return `
|
||||
export const validationSchema = ${source(this.createValidationSchema(data))};
|
||||
|
||||
export type FormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
|
||||
|
||||
export function prepareInitialValues(data: Entity): FormValues {
|
||||
if (data == null) return initialValues;
|
||||
|
||||
return ${this.createPreparedValues(data)}
|
||||
}
|
||||
|
||||
export const initialValues: FormValues = ${this.createInitialValues(data)};
|
||||
`;
|
||||
}
|
||||
|
||||
private createFieldsSource(formConfig: ModuleLayoutConfig): string {
|
||||
return formConfig.grid
|
||||
.map((cell) => this.mapGridCellToComponent(cell))
|
||||
.filter((str) => str != null)
|
||||
.join('');
|
||||
.filter((str): str is string => str != null)
|
||||
.map(source)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private createInitialValues(formConfig: ModuleLayoutConfig): string {
|
||||
@@ -589,4 +610,4 @@ class FormTemplateBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
export default new FormTemplateBuilder();
|
||||
export default new FormSourceBuilder();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
import { SourceFile } from './shared/SourceFile';
|
||||
|
||||
class ListPageSourceBuilder {
|
||||
build(_data: ModuleTableConfig, params: { addTable: boolean }) {
|
||||
const file = new SourceFile();
|
||||
|
||||
file.addImports([
|
||||
"import React from 'react';",
|
||||
"import { Container } from '#shared/widgets/common/Container';",
|
||||
"import { PermissionPageGate } from '#core/Permission';",
|
||||
"import { MODULE_PERMISSION_VIEW } from '../../config/meta';"
|
||||
]);
|
||||
|
||||
if (params.addTable) {
|
||||
file.addImport("import { DataTable } from '../../view/widgets/DataTable';");
|
||||
}
|
||||
|
||||
file.addSection(this.buildComponent(params.addTable ? '<DataTable />' : ''));
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
private buildComponent(tableSource: string) {
|
||||
return `
|
||||
const ListPage: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
|
||||
<Container>
|
||||
${tableSource}
|
||||
</Container>
|
||||
</PermissionPageGate>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPage;
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ListPageSourceBuilder();
|
||||
@@ -0,0 +1,221 @@
|
||||
import { ModuleFormContainer } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
import { SourceFile } from './shared/SourceFile';
|
||||
|
||||
type DialogSize = 'default' | 'md' | 'fullscreen';
|
||||
type SheetSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
interface ManageTriggerContainerConfig {
|
||||
body: string;
|
||||
close: string;
|
||||
content: string;
|
||||
contentProps: string;
|
||||
description: string;
|
||||
disclosureName: string;
|
||||
footer: string;
|
||||
header: string;
|
||||
root: string;
|
||||
title: string;
|
||||
trigger: string;
|
||||
}
|
||||
|
||||
class ManageTriggerSourceBuilder {
|
||||
build(container: ModuleFormContainer) {
|
||||
if (this.isSheet(container)) {
|
||||
return this.buildSheet(this.getSheetSide(container));
|
||||
}
|
||||
|
||||
return this.buildDialog(this.getDialogSize(container));
|
||||
}
|
||||
|
||||
private isSheet(
|
||||
container: ModuleFormContainer
|
||||
): container is Extract<ModuleFormContainer, `sheet-${string}`> {
|
||||
return container.startsWith('sheet-');
|
||||
}
|
||||
|
||||
private getSheetSide(container: Extract<ModuleFormContainer, `sheet-${string}`>): SheetSide {
|
||||
switch (container) {
|
||||
case 'sheet-top':
|
||||
return 'top';
|
||||
case 'sheet-right':
|
||||
return 'right';
|
||||
case 'sheet-bottom':
|
||||
return 'bottom';
|
||||
case 'sheet-left':
|
||||
return 'left';
|
||||
}
|
||||
}
|
||||
|
||||
private getDialogSize(container: Extract<ModuleFormContainer, `dialog-${string}`>): DialogSize {
|
||||
switch (container) {
|
||||
case 'dialog-default':
|
||||
return 'default';
|
||||
case 'dialog-md':
|
||||
return 'md';
|
||||
case 'dialog-fullscreen':
|
||||
return 'fullscreen';
|
||||
}
|
||||
}
|
||||
|
||||
private buildDialog(dialogSize: DialogSize) {
|
||||
const file = new SourceFile();
|
||||
|
||||
file.addImports([
|
||||
"import React from 'react';",
|
||||
"import { LoaderCircleIcon } from 'lucide-react';",
|
||||
"import { useIntl } from 'react-intl';",
|
||||
"import { Button } from '#shared/ui/Button';",
|
||||
`
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '#shared/ui/Dialog';
|
||||
`,
|
||||
"import { Skeleton } from '#shared/ui/Skeleton';",
|
||||
"import { useDisclosure } from '@crux-kit/hooks';",
|
||||
"import { useGetDetailed } from '../../api/detailed';",
|
||||
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';"
|
||||
]);
|
||||
|
||||
file.addSection(
|
||||
this.buildManageTriggerComponent({
|
||||
body: 'DialogBody',
|
||||
close: 'DialogClose',
|
||||
content: 'DialogContent',
|
||||
contentProps: `variant="${dialogSize}"`,
|
||||
description: 'DialogDescription',
|
||||
disclosureName: 'dialog',
|
||||
footer: 'DialogFooter',
|
||||
header: 'DialogHeader',
|
||||
root: 'Dialog',
|
||||
title: 'DialogTitle',
|
||||
trigger: 'DialogTrigger'
|
||||
})
|
||||
);
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
private buildSheet(side: SheetSide) {
|
||||
const file = new SourceFile();
|
||||
|
||||
file.addImports([
|
||||
"import React from 'react';",
|
||||
"import { LoaderCircleIcon } from 'lucide-react';",
|
||||
"import { useIntl } from 'react-intl';",
|
||||
"import { Button } from '#shared/ui/Button';",
|
||||
`
|
||||
import {
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '#shared/ui/Sheet';
|
||||
`,
|
||||
"import { Skeleton } from '#shared/ui/Skeleton';",
|
||||
"import { useDisclosure } from '@crux-kit/hooks';",
|
||||
"import { useGetDetailed } from '../../api/detailed';",
|
||||
"import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';"
|
||||
]);
|
||||
|
||||
file.addSection(
|
||||
this.buildManageTriggerComponent({
|
||||
body: 'SheetBody',
|
||||
close: 'SheetClose',
|
||||
content: 'SheetContent',
|
||||
contentProps: `side="${side}" className="max-h-dvh"`,
|
||||
description: 'SheetDescription',
|
||||
disclosureName: 'sheet',
|
||||
footer: 'SheetFooter',
|
||||
header: 'SheetHeader',
|
||||
root: 'Sheet',
|
||||
title: 'SheetTitle',
|
||||
trigger: 'SheetTrigger'
|
||||
})
|
||||
);
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
|
||||
private buildManageTriggerComponent(config: ManageTriggerContainerConfig) {
|
||||
const bodyProps = config.body === 'SheetBody' ? ' className="overflow-y-auto"' : '';
|
||||
|
||||
return `
|
||||
export const ManageTrigger: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
title: string;
|
||||
id: number | null;
|
||||
}>
|
||||
> = ({ id, children, title }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const ${config.disclosureName} = useDisclosure(false);
|
||||
|
||||
const { data, isLoading } = useGetDetailed(
|
||||
id == null || ${config.disclosureName}.isOpen === false ? undefined : { id: String(id) },
|
||||
);
|
||||
|
||||
const { isSubmitting, submit } = useSubmit({ onSuccess: ${config.disclosureName}.close });
|
||||
|
||||
return (
|
||||
<${config.root} open={${config.disclosureName}.isOpen} onOpenChange={${config.disclosureName}.toggle}>
|
||||
<${config.trigger} asChild>{children}</${config.trigger}>
|
||||
<${config.content} ${config.contentProps}>
|
||||
<${config.header}>
|
||||
<${config.title}>{title}</${config.title}>
|
||||
<${config.description} className="sr-only"></${config.description}>
|
||||
</${config.header}>
|
||||
<${config.body}${bodyProps}>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
</div>
|
||||
) : (
|
||||
<MainForm
|
||||
initialValues={data ? prepareInitialValues(data) : initialValues}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</${config.body}>
|
||||
<${config.footer}>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage({ id: 'STATE.SAVING' })}...
|
||||
</span>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'ACTION.SAVE' })
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<${config.close} asChild>
|
||||
<Button variant="outline">
|
||||
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
|
||||
</Button>
|
||||
</${config.close}>
|
||||
</${config.footer}>
|
||||
</${config.content}>
|
||||
</${config.root}>
|
||||
);
|
||||
};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ManageTriggerSourceBuilder();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SourceFile } from './shared/SourceFile';
|
||||
|
||||
class MenuSourceBuilder {
|
||||
build() {
|
||||
const file = new SourceFile();
|
||||
|
||||
file.addImports([
|
||||
"import { NotepadText } from 'lucide-react'",
|
||||
"import { IntlShape } from 'react-intl'",
|
||||
"import { MenuItem } from '#app/config/types'",
|
||||
"import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta'"
|
||||
]);
|
||||
|
||||
file.addSection(`
|
||||
export default (intl: IntlShape): MenuItem => ({
|
||||
title: intl.formatMessage({
|
||||
id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`,
|
||||
defaultMessage: MODULE_NAME_LABEL,
|
||||
}),
|
||||
icon: NotepadText,
|
||||
path: MODULE_ROOT_PATH,
|
||||
permissions: [MODULE_PERMISSION_VIEW],
|
||||
})
|
||||
`);
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export default new MenuSourceBuilder();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SourceFile } from './shared/SourceFile';
|
||||
|
||||
class ModuleConfigSourceBuilder {
|
||||
build(moduleId: string) {
|
||||
const file = new SourceFile();
|
||||
|
||||
file.addImports([
|
||||
"import { AppModuleConfig } from '#app/services/modules/types';",
|
||||
"import menu from './menu';",
|
||||
"import messages from './messages';",
|
||||
"import routes from './routing';"
|
||||
]);
|
||||
|
||||
file.addSection(`
|
||||
export default {
|
||||
id: '${moduleId}',
|
||||
area: 'business',
|
||||
routeScope: 'protected',
|
||||
order: 100,
|
||||
routes,
|
||||
menu,
|
||||
messages,
|
||||
} satisfies AppModuleConfig;
|
||||
`);
|
||||
|
||||
return file.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModuleConfigSourceBuilder();
|
||||
@@ -1,223 +0,0 @@
|
||||
import { ModuleFormContainer } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
|
||||
class FormDialogTemplateBuilder {
|
||||
build(container: ModuleFormContainer) {
|
||||
if (this.isSheet(container)) {
|
||||
return this.buildSheet(this.getSheetSide(container));
|
||||
}
|
||||
|
||||
return this.buildDialog(this.getDialogSize(container));
|
||||
}
|
||||
|
||||
private isSheet(
|
||||
container: ModuleFormContainer
|
||||
): container is Extract<ModuleFormContainer, `sheet-${string}`> {
|
||||
return container.startsWith('sheet-');
|
||||
}
|
||||
|
||||
private getSheetSide(
|
||||
container: Extract<ModuleFormContainer, `sheet-${string}`>
|
||||
): 'top' | 'right' | 'bottom' | 'left' {
|
||||
switch (container) {
|
||||
case 'sheet-top':
|
||||
return 'top';
|
||||
case 'sheet-right':
|
||||
return 'right';
|
||||
case 'sheet-bottom':
|
||||
return 'bottom';
|
||||
case 'sheet-left':
|
||||
return 'left';
|
||||
}
|
||||
}
|
||||
|
||||
private getDialogSize(
|
||||
container: Extract<ModuleFormContainer, `dialog-${string}`>
|
||||
): 'default' | 'md' | 'fullscreen' {
|
||||
switch (container) {
|
||||
case 'dialog-default':
|
||||
return 'default';
|
||||
case 'dialog-md':
|
||||
return 'md';
|
||||
case 'dialog-fullscreen':
|
||||
return 'fullscreen';
|
||||
}
|
||||
}
|
||||
|
||||
private buildDialog(dialogSize: 'default' | 'md' | 'fullscreen') {
|
||||
const raw = `
|
||||
import React from 'react';
|
||||
import { LoaderCircleIcon } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '#shared/ui/Dialog';
|
||||
import { Skeleton } from '#shared/ui/Skeleton';
|
||||
import { useDisclosure } from '@crux-kit/hooks';
|
||||
import { useGetDetailed } from '../../api/detailed';
|
||||
import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';
|
||||
|
||||
export const ManageTrigger: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
title: string;
|
||||
id: number | null;
|
||||
}>
|
||||
> = ({ id, children, title }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dialog = useDisclosure(false);
|
||||
|
||||
const { data, isLoading } = useGetDetailed(
|
||||
id == null || dialog.isOpen === false ? undefined : { id: String(id) },
|
||||
);
|
||||
|
||||
const { isSubmitting, submit } = useSubmit({ onSuccess: dialog.close });
|
||||
|
||||
return (
|
||||
<Dialog open={dialog.isOpen} onOpenChange={dialog.toggle}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent variant="${dialogSize}">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only"></DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
</div>
|
||||
) : (
|
||||
<MainForm
|
||||
initialValues={data ? prepareInitialValues(data) : initialValues}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage({ id: 'STATE.SAVING' })}...
|
||||
</span>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'ACTION.SAVE' })
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">
|
||||
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
|
||||
private buildSheet(side: 'top' | 'right' | 'bottom' | 'left') {
|
||||
const raw = `
|
||||
import React from 'react';
|
||||
import { LoaderCircleIcon } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '#shared/ui/Sheet';
|
||||
import { Skeleton } from '#shared/ui/Skeleton';
|
||||
import { useDisclosure } from '@crux-kit/hooks';
|
||||
import { useGetDetailed } from '../../api/detailed';
|
||||
import { initialValues, FORM_ID, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';
|
||||
|
||||
export const ManageTrigger: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
title: string;
|
||||
id: number | null;
|
||||
}>
|
||||
> = ({ id, children, title }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const sheet = useDisclosure(false);
|
||||
|
||||
const { data, isLoading } = useGetDetailed(
|
||||
id == null || sheet.isOpen === false ? undefined : { id: String(id) },
|
||||
);
|
||||
|
||||
const { isSubmitting, submit } = useSubmit({ onSuccess: sheet.close });
|
||||
|
||||
return (
|
||||
<Sheet open={sheet.isOpen} onOpenChange={sheet.toggle}>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
<SheetContent side="${side}" className="max-h-dvh">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription className="sr-only"></SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetBody className="overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
</div>
|
||||
) : (
|
||||
<MainForm
|
||||
initialValues={data ? prepareInitialValues(data) : initialValues}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage({ id: 'STATE.SAVING' })}...
|
||||
</span>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'ACTION.SAVE' })
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">
|
||||
{intl.formatMessage({ id: 'ACTION.CANCEL' })}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default new FormDialogTemplateBuilder();
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ModuleTableConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
|
||||
class ListPageTemplateBuilder {
|
||||
build(_data: ModuleTableConfig, params: { addTable: boolean }) {
|
||||
const imports = [
|
||||
"import React from 'react';",
|
||||
"import { Container } from '#shared/widgets/common/Container';",
|
||||
"import { PermissionPageGate } from '#core/Permission';",
|
||||
"import { MODULE_PERMISSION_VIEW } from '../../config/meta';"
|
||||
];
|
||||
|
||||
let table = '';
|
||||
|
||||
if (params.addTable) {
|
||||
imports.push("import { DataTable } from '../../view/widgets/DataTable';");
|
||||
table = '<DataTable />';
|
||||
}
|
||||
|
||||
const raw = `
|
||||
${imports.join('\n')}
|
||||
\n
|
||||
const ListPage: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
|
||||
<Container>
|
||||
${table}
|
||||
</Container>
|
||||
</PermissionPageGate>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPage;
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default new ListPageTemplateBuilder();
|
||||
@@ -1,24 +0,0 @@
|
||||
class MenuTemplateBuilder {
|
||||
build() {
|
||||
const raw = `
|
||||
import { NotepadText } from 'lucide-react'
|
||||
import { IntlShape } from 'react-intl'
|
||||
import { MenuItem } from '#app/config/types'
|
||||
import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta'
|
||||
|
||||
export default (intl: IntlShape): MenuItem => ({
|
||||
title: intl.formatMessage({
|
||||
id: \`MODULES.\${MODULE_NAME_LABEL.toUpperCase()}.TITLE\`,
|
||||
defaultMessage: MODULE_NAME_LABEL,
|
||||
}),
|
||||
icon: NotepadText,
|
||||
path: MODULE_ROOT_PATH,
|
||||
permissions: [MODULE_PERMISSION_VIEW],
|
||||
})
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default new MenuTemplateBuilder();
|
||||
@@ -1,24 +0,0 @@
|
||||
class ModuleTemplateBuilder {
|
||||
build(moduleId: string) {
|
||||
const raw = `
|
||||
import { AppModuleConfig } from '#app/services/modules/types';
|
||||
import menu from './menu';
|
||||
import messages from './messages';
|
||||
import routes from './routing';
|
||||
|
||||
export default {
|
||||
id: '${moduleId}',
|
||||
area: 'business',
|
||||
routeScope: 'protected',
|
||||
order: 100,
|
||||
routes,
|
||||
menu,
|
||||
messages,
|
||||
} satisfies AppModuleConfig;
|
||||
`;
|
||||
|
||||
return raw.replace(/\{\s*'\s*'\s*\}/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModuleTemplateBuilder();
|
||||
@@ -0,0 +1,65 @@
|
||||
type SourceChunk = string | null | false | undefined;
|
||||
|
||||
const EMPTY_JSX_TEXT_EXPRESSION_REGEX = /\{\s*'\s*'\s*\}/g;
|
||||
|
||||
function normalizeSource(value: string): string {
|
||||
const source = value.replace(/^\n/, '').replace(/\n\s*$/, '');
|
||||
const lines = source.split('\n');
|
||||
const indents = lines
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => line.match(/^\s*/)?.[0].length ?? 0);
|
||||
|
||||
const minIndent = indents.length > 0 ? Math.min(...indents) : 0;
|
||||
|
||||
return lines
|
||||
.map((line) => line.slice(Math.min(line.match(/^\s*/)?.[0].length ?? 0, minIndent)))
|
||||
.join('\n')
|
||||
.replace(EMPTY_JSX_TEXT_EXPRESSION_REGEX, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function source(value: string): string {
|
||||
return normalizeSource(value);
|
||||
}
|
||||
|
||||
export function indentSource(value: string, spaces: number): string {
|
||||
const prefix = ' '.repeat(spaces);
|
||||
|
||||
return normalizeSource(value)
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? `${prefix}${line}` : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export class SourceFile {
|
||||
private readonly imports = new Set<string>();
|
||||
private readonly sections: string[] = [];
|
||||
|
||||
addImport(value: SourceChunk): this {
|
||||
if (value) {
|
||||
this.imports.add(normalizeSource(value));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addImports(values: SourceChunk[]): this {
|
||||
values.forEach((value) => this.addImport(value));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addSection(value: SourceChunk): this {
|
||||
if (value) {
|
||||
this.sections.push(normalizeSource(value));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return [Array.from(this.imports).join('\n'), this.sections.join('\n\n')]
|
||||
.filter((section) => section.length > 0)
|
||||
.join('\n\n');
|
||||
}
|
||||
}
|
||||
+13
-15
@@ -1,10 +1,10 @@
|
||||
import { GenerateModulePayload } from '#devtools/data/api/dto';
|
||||
import DataTableTemplateBuilder from '#devtools/data/builders/DataTableTemplateBuilder';
|
||||
import FormDialogTemplateBuilder from '#devtools/data/builders/formDialogTemplateBuilder';
|
||||
import FormTemplateBuilder from '#devtools/data/builders/FormTemplateBuilder';
|
||||
import ListPageTemplateBuilder from '#devtools/data/builders/ListPageTemplateBuilder';
|
||||
import MenuTemplateBuilder from '#devtools/data/builders/MenuTemplateBuilder';
|
||||
import ModuleTemplateBuilder from '#devtools/data/builders/ModuleTemplateBuilder';
|
||||
import DataTableSourceBuilder from '#devtools/data/builders/DataTableSourceBuilder';
|
||||
import FormSourceBuilder from '#devtools/data/builders/FormSourceBuilder';
|
||||
import ListPageSourceBuilder from '#devtools/data/builders/ListPageSourceBuilder';
|
||||
import ManageTriggerSourceBuilder from '#devtools/data/builders/ManageTriggerSourceBuilder';
|
||||
import MenuSourceBuilder from '#devtools/data/builders/MenuSourceBuilder';
|
||||
import ModuleConfigSourceBuilder from '#devtools/data/builders/ModuleConfigSourceBuilder';
|
||||
import { ModuleAsset } from '#devtools/entities/ModuleAsset';
|
||||
import { ModuleGeneratorConfig } from '#devtools/entities/ModuleGeneratorTypes';
|
||||
|
||||
@@ -18,14 +18,12 @@ export default function buildGenerateModulePayload(
|
||||
name: values.name,
|
||||
table: values.table,
|
||||
generate: values.generate,
|
||||
form: shouldGenerateForm ? FormTemplateBuilder.buildComponent(values.form) : null,
|
||||
formDefinition: shouldGenerateForm ? FormTemplateBuilder.buildDefinition(values.form) : null,
|
||||
dataTable: shouldGenerateDataTable ? DataTableTemplateBuilder.build(values) : null,
|
||||
list: ListPageTemplateBuilder.build(values.datatable, {
|
||||
addTable: shouldGenerateDataTable
|
||||
}),
|
||||
formDialog: shouldGenerateForm ? FormDialogTemplateBuilder.build(values.form.size) : null,
|
||||
menu: shouldGenerateDataTable ? MenuTemplateBuilder.build() : null,
|
||||
module: ModuleTemplateBuilder.build(values.name)
|
||||
form: shouldGenerateForm ? FormSourceBuilder.buildComponent(values.form) : null,
|
||||
formDefinition: shouldGenerateForm ? FormSourceBuilder.buildDefinition(values.form) : null,
|
||||
dataTable: shouldGenerateDataTable ? DataTableSourceBuilder.build(values) : null,
|
||||
list: ListPageSourceBuilder.build(values.datatable, { addTable: shouldGenerateDataTable }),
|
||||
formDialog: shouldGenerateForm ? ManageTriggerSourceBuilder.build(values.form.size) : null,
|
||||
menu: shouldGenerateDataTable ? MenuSourceBuilder.build() : null,
|
||||
module: ModuleConfigSourceBuilder.build(values.name)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user