kira-crud
Version:
Intelligent CRUD Generator for Laravel and Angular
440 lines (382 loc) • 14.1 kB
JavaScript
/**
* Polymorphic Relationship Generator
* Generates Angular components for models with polymorphic relationships
*/
const fs = require('fs').promises;
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const yaml = require('js-yaml');
const { execSync } = require('child_process');
const { pascalCase, kebabCase, camelCase } = require('../utils/string-utils');
/**
* Generate Angular components for a model with polymorphic relationships
* @param {Object} config - The model configuration
* @param {Object} options - Generator options
* @returns {Promise<void>}
*/
async function generatePolymorphicComponents(config, options) {
const spinner = ora('Generating polymorphic components...').start();
try {
// Validate the configuration
validatePolymorphicConfig(config);
// Extract configuration values
const modelName = config.model.name;
const modelNameKebab = kebabCase(modelName);
const modelNameCamel = camelCase(modelName);
// Find polymorphic relationships
const polymorphicRelationships = findPolymorphicRelationships(config.model.relationships);
if (polymorphicRelationships.length === 0) {
spinner.warn('No polymorphic relationships found in the configuration');
return;
}
spinner.text = `Generating components for ${modelName} with ${polymorphicRelationships.length} polymorphic relationships...`;
// Create directories
const componentDir = path.join(options.outputDir, modelNameKebab);
await fs.mkdir(componentDir, { recursive: true });
// Generate component files
await generateComponentFiles(componentDir, config, polymorphicRelationships, options);
spinner.succeed(`Polymorphic components generated successfully for ${modelName}`);
} catch (error) {
spinner.fail(`Failed to generate polymorphic components: ${error.message}`);
throw error;
}
}
/**
* Validate the configuration for polymorphic relationships
* @param {Object} config - The model configuration
*/
function validatePolymorphicConfig(config) {
if (!config.model) {
throw new Error('Invalid configuration: model section is missing');
}
if (!config.model.name) {
throw new Error('Invalid configuration: model name is missing');
}
if (!config.model.relationships || !Array.isArray(config.model.relationships)) {
throw new Error('Invalid configuration: relationships array is missing');
}
}
/**
* Find polymorphic relationships in the model configuration
* @param {Array} relationships - The relationships array from the model configuration
* @returns {Array} - Array of polymorphic relationships
*/
function findPolymorphicRelationships(relationships) {
return relationships.filter(rel =>
rel.type === 'morphTo' || rel.type === 'morphMany'
);
}
/**
* Generate component files for the polymorphic model
* @param {string} componentDir - The component directory
* @param {Object} config - The model configuration
* @param {Array} polymorphicRelationships - The polymorphic relationships
* @param {Object} options - Generator options
*/
async function generateComponentFiles(componentDir, config, polymorphicRelationships, options) {
const modelName = config.model.name;
const modelNameKebab = kebabCase(modelName);
const modelNamePascal = pascalCase(modelName);
const modelNameCamel = camelCase(modelName);
// Read template files
const templatesDir = path.join(process.cwd(), 'cli/templates/polymorphic');
// Read component templates
const tsTemplatePath = path.join(templatesDir, 'angular-component.ts.template');
const htmlTemplatePath = path.join(templatesDir, 'angular-component.html.template');
const tsTemplate = await fs.readFile(tsTemplatePath, 'utf8');
const htmlTemplate = await fs.readFile(htmlTemplatePath, 'utf8');
// Prepare template data
const templateData = {
modelName: modelName,
kebabCase: modelNameKebab,
pascalCase: modelNamePascal,
camelCase: modelNameCamel,
resourceName: modelNameCamel,
displayName: config.model.displayName || modelName,
itemsPerPage: config.ui?.itemsPerPage || 10,
// Handle first polymorphic relationship (currently only supporting one)
polymorphicRelationship: polymorphicRelationships[0],
morphName: polymorphicRelationships[0].name,
morphLabel: polymorphicRelationships[0].ui?.label || polymorphicRelationships[0].name,
defaultDisplayField: 'name',
// Prepare fields for form and table
formFields: prepareFormFields(config.model.fields),
tableColumns: prepareTableColumns(config.model.fields, config.ui?.tableFields),
// Prepare morphable types information
morphableTypes: prepareMorphableTypes(polymorphicRelationships[0])
};
// Replace template variables
const tsContent = replaceTemplateVariables(tsTemplate, templateData);
const htmlContent = replaceTemplateVariables(htmlTemplate, templateData);
// Write component files
await fs.writeFile(path.join(componentDir, `${modelNameKebab}.component.ts`), tsContent);
await fs.writeFile(path.join(componentDir, `${modelNameKebab}.component.html`), htmlContent);
// Create basic SCSS file
await fs.writeFile(
path.join(componentDir, `${modelNameKebab}.component.scss`),
`.${modelNameKebab}-container {\n padding: 20px;\n}`
);
// Generate module file if needed
if (options.generateModule) {
await generateModuleFile(componentDir, modelName);
}
}
/**
* Prepare form fields configuration
* @param {Array} fields - Model fields
* @returns {Array} - Prepared form fields
*/
function prepareFormFields(fields) {
return fields.map(field => {
const formField = {
name: field.name,
label: field.label || field.name,
required: field.validations?.includes('required') || false
};
// Determine input type
switch (field.type) {
case 'text':
formField.isTextarea = true;
break;
case 'boolean':
formField.isSelect = true;
formField.optionsName = 'booleanOptions';
formField.optionLabel = 'label';
formField.optionValue = 'value';
break;
case 'enum':
formField.isSelect = true;
formField.optionsName = `${field.name}Options`;
formField.optionLabel = 'label';
formField.optionValue = 'value';
break;
default:
formField.isInput = true;
formField.inputType = mapTypeToInputType(field.type);
}
return formField;
});
}
/**
* Prepare table columns configuration
* @param {Array} fields - Model fields
* @param {Array} tableFields - Fields to include in the table
* @returns {Array} - Prepared table columns
*/
function prepareTableColumns(fields, tableFields) {
const fieldMap = fields.reduce((map, field) => {
map[field.name] = field;
return map;
}, {});
// If no tableFields specified, use the first few fields
const columnsToUse = tableFields || fields.slice(0, 4).map(f => f.name);
return columnsToUse.map(fieldName => {
const field = fieldMap[fieldName];
if (!field) {
return {
name: fieldName,
label: fieldName,
fieldAccessor: `{{ item.${fieldName} }}`
};
}
return {
name: fieldName,
label: field.label || fieldName,
fieldAccessor: getFieldAccessor(field)
};
});
}
/**
* Prepare morphable types for the template
* @param {Object} relationship - The polymorphic relationship
* @returns {Array} - Prepared morphable types
*/
function prepareMorphableTypes(relationship) {
if (!relationship.polymorphicTypes || !Array.isArray(relationship.polymorphicTypes)) {
return [
{ label: 'Post', value: 'Post', apiRoute: 'posts', modelName: 'Post', displayField: 'title' },
{ label: 'Product', value: 'Product', apiRoute: 'products', modelName: 'Product', displayField: 'name' }
];
}
return relationship.polymorphicTypes.map(type => ({
label: type.model,
value: type.model,
apiRoute: kebabCase(type.model) + 's',
modelName: type.model,
displayField: type.displayField || 'name'
}));
}
/**
* Map data type to input type
* @param {string} type - Data type
* @returns {string} - HTML input type
*/
function mapTypeToInputType(type) {
const typeMap = {
'string': 'text',
'integer': 'number',
'decimal': 'number',
'date': 'date',
'datetime': 'datetime-local',
'email': 'email',
'password': 'password',
'boolean': 'checkbox'
};
return typeMap[type] || 'text';
}
/**
* Get field accessor template string
* @param {Object} field - Field configuration
* @returns {string} - Template accessor
*/
function getFieldAccessor(field) {
switch (field.type) {
case 'boolean':
return `{{ item.${field.name} ? 'Yes' : 'No' }}`;
case 'date':
case 'datetime':
return `{{ item.${field.name} | date }}`;
default:
return `{{ item.${field.name} }}`;
}
}
/**
* Generate module file for the component
* @param {string} componentDir - Component directory
* @param {string} modelName - Model name
*/
async function generateModuleFile(componentDir, modelName) {
const modelNameKebab = kebabCase(modelName);
const modelNamePascal = pascalCase(modelName);
const moduleContent = `import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
// PrimeNG Components
import { TableModule } from 'primeng/table';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { InputTextModule } from 'primeng/inputtext';
import { InputTextareaModule } from 'primeng/inputtextarea';
import { DropdownModule } from 'primeng/dropdown';
import { MultiSelectModule } from 'primeng/multiselect';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { ToastModule } from 'primeng/toast';
import { ${modelNamePascal}Component } from './${modelNameKebab}.component';
@NgModule({
declarations: [
${modelNamePascal}Component
],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule.forChild([
{ path: '', component: ${modelNamePascal}Component }
]),
TableModule,
ButtonModule,
DialogModule,
InputTextModule,
InputTextareaModule,
DropdownModule,
MultiSelectModule,
ConfirmDialogModule,
ToastModule
],
exports: [
${modelNamePascal}Component
]
})
export class ${modelNamePascal}Module { }`;
await fs.writeFile(path.join(componentDir, `${modelNameKebab}.module.ts`), moduleContent);
}
/**
* Replace template variables with actual values
* @param {string} template - Template string
* @param {Object} data - Data for replacement
* @returns {string} - Processed template
*/
function replaceTemplateVariables(template, data) {
let content = template;
// Simple variable replacements
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'string' || typeof value === 'number') {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
content = content.replace(regex, value);
}
}
// Array-based replacements with {{#section}} ... {{/section}} blocks
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value) && value.length > 0) {
// Handle array sections
const pattern = new RegExp(`{{#${key}}}([\\s\\S]*?){{\\/${key}}}`, 'g');
let match;
while ((match = pattern.exec(template)) !== null) {
const fullMatch = match[0];
const sectionTemplate = match[1];
let replacement = '';
for (const item of value) {
let itemReplacement = sectionTemplate;
if (typeof item === 'object') {
for (const [itemKey, itemValue] of Object.entries(item)) {
if (typeof itemValue === 'string' || typeof itemValue === 'number' || typeof itemValue === 'boolean') {
const itemRegex = new RegExp(`{{\\s*${itemKey}\\s*}}`, 'g');
itemReplacement = itemReplacement.replace(itemRegex, itemValue);
}
}
} else {
const dotRegex = new RegExp(`{{\\s*\\.\\s*}}`, 'g');
itemReplacement = itemReplacement.replace(dotRegex, item);
}
replacement += itemReplacement;
}
content = content.replace(fullMatch, replacement);
}
} else if (typeof value === 'boolean') {
// Handle boolean sections
const truePattern = new RegExp(`{{#${key}}}([\\s\\S]*?){{\\/${key}}}`, 'g');
const falsePattern = new RegExp(`{{^${key}}}([\\s\\S]*?){{\\/${key}}}`, 'g');
// Handle true case
if (value) {
let match;
while ((match = truePattern.exec(template)) !== null) {
const fullMatch = match[0];
const sectionTemplate = match[1];
content = content.replace(fullMatch, sectionTemplate);
}
// Remove false cases
content = content.replace(falsePattern, '');
} else {
// Handle false case
let match;
while ((match = falsePattern.exec(template)) !== null) {
const fullMatch = match[0];
const sectionTemplate = match[1];
content = content.replace(fullMatch, sectionTemplate);
}
// Remove true cases
content = content.replace(truePattern, '');
}
}
}
return content;
}
/**
* Load a YAML configuration file
* @param {string} filePath - Path to configuration file
* @returns {Promise<Object>} - Parsed configuration
*/
async function loadConfig(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
return yaml.load(content);
} catch (error) {
throw new Error(`Failed to load configuration: ${error.message}`);
}
}
module.exports = {
generatePolymorphicComponents,
loadConfig
};