kira-crud
Version:
Intelligent CRUD Generator for Laravel and Angular
583 lines (507 loc) • 19.4 kB
JavaScript
/**
* Angular Custom Generator
* Generates Angular components following the project structure
*/
const fs = require('fs').promises;
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const yaml = require('js-yaml');
const BaseGenerator = require('./base-generator');
const { pascalCase, camelCase, kebabCase, pluralize } = require('../utils/string-utils');
/**
* Generator for custom Angular components
*/
class AngularCustomGenerator extends BaseGenerator {
/**
* Create a new generator
* @param {Object} options - Generator options
*/
constructor(options = {}) {
super(options);
// Default output path
this.outputBasePath = options.outputPath || 'front/src/app/pages/admin/settings';
// Use dirname to get absolute path to templates, which works for global installs
this.templateDir = options.templateDir || path.join(__dirname, '../templates/angular-custom');
this.modelData = null;
}
/**
* Validate the configuration
* @returns {Promise<void>}
*/
async validate() {
if (!this.config || !this.config.model) {
this.addError('Invalid configuration: model section is missing');
return;
}
if (!this.config.model.name) {
this.addError('Invalid configuration: model name is missing');
return;
}
if (!this.config.model.fields || !Array.isArray(this.config.model.fields) || this.config.model.fields.length === 0) {
this.addError('Invalid configuration: model must have at least one field');
return;
}
// Check for required display field
const hasDisplayField = this.config.model.fields.some(field =>
field.name === (this.config.model.displayField || 'name')
);
if (!hasDisplayField) {
this.addWarning(`Model does not have a display field named '${this.config.model.displayField || 'name'}'`);
}
// Validate UI configuration
if (!this.config.ui) {
this.config.ui = {};
this.addWarning('No UI configuration provided, using defaults');
}
// Check searchable fields
if (!this.config.ui.searchableFields || !Array.isArray(this.config.ui.searchableFields) || this.config.ui.searchableFields.length === 0) {
// Default to first string field
const firstStringField = this.config.model.fields.find(field => field.type === 'string');
if (firstStringField) {
this.config.ui.searchableFields = [firstStringField.name];
this.addWarning(`No searchable fields defined, using '${firstStringField.name}' as default`);
} else {
this.config.ui.searchableFields = ['id'];
this.addWarning('No searchable fields defined and no string fields found, using id as default');
}
}
// Check table fields
if (!this.config.ui.tableFields || !Array.isArray(this.config.ui.tableFields) || this.config.ui.tableFields.length === 0) {
// Default to first few fields
this.config.ui.tableFields = this.config.model.fields.slice(0, 3).map(field => field.name);
this.addWarning(`No table fields defined, using first ${this.config.ui.tableFields.length} fields as default`);
}
return true;
}
/**
* Prepare for generation
* @returns {Promise<void>}
*/
async prepare() {
// Prepare model data for templates
const modelName = this.config.model.name;
this.modelData = {
pascalCase: pascalCase(modelName),
camelCase: camelCase(modelName),
kebabCase: kebabCase(modelName),
pluralCamelCase: camelCase(pluralize(modelName)),
pluralPascalCase: pascalCase(pluralize(modelName)),
pluralKebabCase: kebabCase(pluralize(modelName)),
displayName: this.config.model.displayName || modelName,
pluralDisplay: this.config.model.pluralDisplayName || pluralize(this.config.model.displayName || modelName),
displayField: this.config.model.displayField || 'name',
isFeminine: (this.config.ui?.isFeminine || false).toString(),
// Field data
fields: this.prepareFields(),
// Relationship data
relationships: this.prepareRelationships(),
// UI configuration
tableColumns: this.prepareTableColumns(),
formFields: this.prepareFormFields(),
searchableFields: this.config.ui.searchableFields || ['name'],
// Additional options
additionalProperties: this.config.additionalProperties || [],
customStyles: this.config.customStyles || []
};
// Create output directory
const outputDir = path.join(this.outputBasePath, this.modelData.kebabCase);
await this.transaction.addMkdir(outputDir);
return true;
}
/**
* Generate the code
* @returns {Promise<void>}
*/
async generate() {
// Get output directory
const outputDir = path.join(this.outputBasePath, this.modelData.kebabCase);
// Generate component files
await this.generateComponentFiles(outputDir);
return true;
}
/**
* Generate component files
* @param {string} outputDir - Output directory
* @returns {Promise<void>}
*/
async generateComponentFiles(outputDir) {
// Read template files
const templateFiles = await fs.readdir(this.templateDir);
for (const templateFile of templateFiles) {
// Skip non-template files
if (!templateFile.endsWith('.template')) {
continue;
}
// Skip relation-modal templates unless specifically needed
if (templateFile.includes('relation-modal.component') &&
(!this.modelData.relationships || this.modelData.relationships.length === 0 ||
!this.modelData.relationships.some(rel => rel.isHasMany || rel.isHasOne || rel.isBelongsToMany))) {
console.log(`Skipping ${templateFile} as no relationships require it`);
continue;
}
// Read template content
const templatePath = path.join(this.templateDir, templateFile);
const templateContent = await fs.readFile(templatePath, 'utf8');
// Get output file name
const outputFileName = templateFile
.replace('.template', '')
.replace('component', this.modelData.kebabCase + '.component');
const outputFilePath = path.join(outputDir, outputFileName);
// Process template
const processedContent = this.processTemplate(templateContent, this.modelData);
// Add to transaction
await this.transaction.addWrite(outputFilePath, processedContent);
}
}
/**
* Prepare fields data for templates
* @returns {Array} Processed fields
*/
prepareFields() {
return this.config.model.fields.map(field => {
// Déterminer si le champ est requis
const isRequired = field.required ||
(field.validationRules && field.validationRules.required) ||
(field.validations && Array.isArray(field.validations) && field.validations.includes('required')) ||
(field.nullable === false);
return {
name: field.name,
type: field.type,
label: field.label || this.formatLabel(field.name),
required: isRequired,
nullable: (field.nullable || false),
tsType: this.mapTypeToTsType(field.type)
};
});
}
/**
* Prepare relationships data for templates
* @returns {Array} Processed relationships
*/
prepareRelationships() {
if (!this.config.model.relationships) {
return [];
}
return this.config.model.relationships.map(rel => {
const foreignKey = rel.foreignKey || (rel.name + '_id');
const modelName = rel.relatedModel || rel.model || rel.name;
return {
name: rel.name,
type: rel.type,
model: modelName,
relatedModel: rel.relatedModel,
foreignKey,
isBelongsTo: rel.type === 'belongsTo',
isHasMany: rel.type === 'hasMany',
isHasOne: rel.type === 'hasOne',
isBelongsToMany: rel.type === 'belongsToMany',
isMorphTo: rel.type === 'morphTo',
displayField: rel.displayField || 'name',
label: rel.label || this.formatLabel(rel.name),
required: rel.required || false,
apiRoute: rel.apiRoute
};
});
}
/**
* Prepare table columns for templates
* @returns {Array} Processed table columns
*/
prepareTableColumns() {
const columns = [];
const fieldMap = {};
// Create a map of field names to field objects
this.config.model.fields.forEach(field => {
fieldMap[field.name] = field;
});
// Process each table field
if (this.config.ui && this.config.ui.tableFields && Array.isArray(this.config.ui.tableFields)) {
this.config.ui.tableFields.forEach(fieldName => {
// Check if field exists
if (fieldMap[fieldName]) {
columns.push({
field: fieldName,
header: fieldMap[fieldName].label || this.formatLabel(fieldName),
filterType: this.mapTypeToFilterType(fieldMap[fieldName].type)
});
} else {
// Check if it's a relationship field
const relationField = this.findRelationField(fieldName);
if (relationField) {
columns.push({
field: fieldName,
header: relationField.label || this.formatLabel(fieldName),
filterType: 'text'
});
} else {
// Check if it's a nested relation field (e.g. "departement.libelle")
if (fieldName.includes('.')) {
const [relationName, relationField] = fieldName.split('.');
const relation = this.findRelationField(relationName);
if (relation) {
columns.push({
field: fieldName,
header: relation.label ? `${relation.label} ${this.formatLabel(relationField)}` : this.formatLabel(fieldName),
filterType: 'text'
});
} else {
// Add anyway, might be a derived field
columns.push({
field: fieldName,
header: this.formatLabel(fieldName),
filterType: 'text'
});
}
} else {
// Add anyway, might be a derived field
columns.push({
field: fieldName,
header: this.formatLabel(fieldName),
filterType: 'text'
});
}
}
}
});
}
// Ajouter automatiquement les relations BelongsTo qui ont showInTable=true
if (this.config.model.relationships) {
this.config.model.relationships.forEach(rel => {
if (rel.type === 'belongsTo' && rel.ui && rel.ui.showInTable) {
// Vérifier si la relation est déjà incluse
const relationField = `${rel.name}.${rel.displayField || 'name'}`;
const alreadyExists = columns.some(col => col.field === relationField);
if (!alreadyExists) {
columns.push({
field: relationField,
header: rel.ui.label || rel.label || this.formatLabel(rel.name),
filterType: 'text'
});
}
}
});
}
return columns;
}
/**
* Prepare form fields for templates
* @returns {Array} Processed form fields
*/
prepareFormFields() {
const formFields = [];
// Add regular fields
this.config.model.fields.forEach(field => {
// Skip id field
if (field.name === 'id') {
return;
}
// Skip timestamp fields
if (['created_at', 'updated_at'].includes(field.name)) {
return;
}
// Déterminer si le champ est requis (suppression de la validation de type strict pour required)
const isRequired = field.required ||
(field.validationRules && field.validationRules.required) ||
(field.validations && Array.isArray(field.validations) && field.validations.includes('required')) ||
(field.nullable === false);
const formField = {
name: field.name,
label: field.label || this.formatLabel(field.name),
placeholder: field.placeholder || `Entrez ${this.formatLabel(field.name).toLowerCase()}`,
required: isRequired,
type: field.type
};
// Explicitement initialiser tous les types à false
formField.isText = false;
formField.isTextarea = false;
formField.isNumber = false;
formField.isDate = false;
formField.isBoolean = false;
formField.isDropdown = false;
formField.isCustom = false;
formField.hasSource = false;
formField.isRelationList = false;
// Puis définir le bon type à true
if (['string', 'email', 'password', 'url'].includes(field.type)) {
formField.isText = true;
} else if (field.type === 'text') {
formField.isTextarea = true;
} else if (['integer', 'decimal', 'float'].includes(field.type)) {
formField.isNumber = true;
} else if (['date', 'datetime'].includes(field.type)) {
formField.isDate = true;
} else if (field.type === 'boolean') {
formField.isBoolean = true;
} else if (field.type === 'enum' || field.hasOptions) {
formField.isDropdown = true;
} else if (field.customTemplate !== undefined) {
formField.isCustom = true;
}
// Set required flag
formField.required = field.required || false;
// Add options for dropdown if needed
if (formField.isDropdown && field.options) {
formField.options = field.options;
}
formFields.push(formField);
});
// Add relationship fields
if (this.config.model.relationships) {
this.config.model.relationships.forEach(rel => {
if (rel.type === 'belongsTo' || rel.type === 'morphTo') {
const foreignKey = rel.foreignKey || `${rel.name}_id`;
const formField = {
name: foreignKey,
label: rel.label || this.formatLabel(rel.name),
placeholder: `Sélectionnez ${this.formatLabel(rel.name).toLowerCase()}`,
required: rel.required || false,
type: 'relation',
isDropdown: true,
isText: false,
isTextarea: false,
isNumber: false,
isDate: false,
isBoolean: false,
isCustom: false,
isRelationList: false,
hasSource: true,
sourceName: rel.name,
sourceType: 'api',
apiRoute: this.getApiRouteForRelation(rel),
optionLabel: rel.displayField || 'name',
optionValue: 'id'
};
formFields.push(formField);
} else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
// Pour les relations hasMany et hasOne, on ajoute un champ spécial pour afficher la relation
// mais pas dans le formulaire standard car ces relations sont gérées différemment
if (rel.ui && rel.ui.showInForm) {
const formField = {
name: rel.name,
label: rel.label || this.formatLabel(rel.name),
placeholder: `Gérer ${this.formatLabel(rel.name).toLowerCase()}`,
required: false,
type: 'relation',
isDropdown: false,
isText: false,
isTextarea: false,
isNumber: false,
isDate: false,
isBoolean: false,
isCustom: false,
isRelationList: true,
hasSource: true,
sourceName: rel.name,
sourceType: 'api',
relationType: rel.type,
apiRoute: this.getApiRouteForRelation(rel),
optionLabel: rel.displayField || 'name',
optionValue: 'id',
relatedModel: rel.relatedModel || rel.model
};
formFields.push(formField);
}
}
});
}
return formFields;
}
/**
* Get API route for a relationship
* @param {Object} relation - Relationship object
* @returns {string} API route
*/
getApiRouteForRelation(relation) {
if (relation.apiRoute) {
return relation.apiRoute;
}
// Utiliser relatedModel s'il existe, sinon model
const modelName = relation.relatedModel || relation.model;
if (!modelName) {
console.warn(`Attention: relation ${relation.name} n'a pas de modèle défini`);
return kebabCase(pluralize(relation.name)); // Fallback au nom de la relation
}
// Default to pluralized kebab-case model name
return kebabCase(pluralize(modelName));
}
/**
* Find a relationship field by name
* @param {string} fieldName - Field name
* @returns {Object|null} Relationship field or null
*/
findRelationField(fieldName) {
if (!this.config.model.relationships) {
return null;
}
// Check if it's a direct relationship name
const directRelation = this.config.model.relationships.find(rel => rel.name === fieldName);
if (directRelation) {
return directRelation;
}
// Check if it's a relationship property (e.g., "category.name")
if (fieldName.includes('.')) {
const [relationName] = fieldName.split('.');
return this.config.model.relationships.find(rel => rel.name === relationName);
}
// Check if it's a foreign key
return this.config.model.relationships.find(rel =>
(rel.foreignKey || `${rel.name}_id`) === fieldName
);
}
/**
* Format a field name as a label
* @param {string} name - Field name
* @returns {string} Formatted label
*/
formatLabel(name) {
// Handle camelCase
const spacedName = name.replace(/([A-Z])/g, ' $1').trim();
// Handle snake_case
const withoutUnderscores = spacedName.replace(/_/g, ' ');
// Capitalize first letter
return withoutUnderscores.charAt(0).toUpperCase() + withoutUnderscores.slice(1);
}
/**
* Map field type to TypeScript type
* @param {string} type - Field type
* @returns {string} TypeScript type
*/
mapTypeToTsType(type) {
const typeMap = {
'string': 'string',
'text': 'string',
'integer': 'number',
'decimal': 'number',
'float': 'number',
'boolean': 'boolean',
'date': 'Date',
'datetime': 'Date',
'json': 'any',
'array': 'any[]',
'object': 'object',
'enum': 'string'
};
return typeMap[type] || 'any';
}
/**
* Map field type to filter type
* @param {string} type - Field type
* @returns {string} Filter type
*/
mapTypeToFilterType(type) {
const filterMap = {
'string': 'text',
'text': 'text',
'integer': 'numeric',
'decimal': 'numeric',
'float': 'numeric',
'boolean': 'boolean',
'date': 'date',
'datetime': 'date',
'enum': 'text'
};
return filterMap[type] || 'text';
}
}
module.exports = AngularCustomGenerator;