UNPKG

kira-crud

Version:

Intelligent CRUD Generator for Laravel and Angular

583 lines (507 loc) 19.4 kB
/** * 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;