@codehance/rapid-stack
Version:
A modern full-stack development toolkit for rapid application development
1,254 lines (1,103 loc) • 44.2 kB
JavaScript
const Generator = require('yeoman-generator');
const path = require('path');
const fs = require('fs');
module.exports = class extends Generator {
// Add this helper method to parse GraphQL operations from Ruby files
_parseGraphQLOperations(type) {
const filePath = path.join(process.cwd(), 'backend/app/graphql/types', `${type}_type.rb`);
try {
const content = fs.readFileSync(filePath, 'utf8');
// Parse field definitions using regex
const fieldRegex = /field\s+:(\w+)/g;
const operations = [];
let match;
while ((match = fieldRegex.exec(content)) !== null) {
operations.push(match[1]);
}
return operations;
} catch (error) {
this.log.error(`Error reading ${type} type file:`, error.message);
return [];
}
}
// Add this helper method to find and parse mutation/query files
_findGraphQLOperation(operationType, operationName) {
const baseDir = path.join(process.cwd(), 'backend/app/graphql', `${operationType}s`);
const snakeCaseName = operationName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
let operationFile = null;
// Recursive function to find the file
const findFile = (dir) => {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
const found = findFile(filePath);
if (found) return found;
} else if (file === `${snakeCaseName}.rb`) {
return filePath;
}
}
return null;
};
operationFile = findFile(baseDir);
if (!operationFile) {
this.log.error(`Could not find ${operationType} file for ${operationName}`);
return null;
}
// Parse the Ruby file to extract arguments
const content = fs.readFileSync(operationFile, 'utf8');
const fields = [];
// Updated regex to better match Ruby argument definitions
const lines = content.split('\n');
for (const line of lines) {
// Log the line being processed
console.log('Processing line:', line);
const argumentMatch = line.match(/argument\s+:(\w+),\s*(\w+)(?:,\s*required:\s*(true|false))?/);
if (argumentMatch) {
console.log('Argument match:', argumentMatch); // Debug log
const field = {
name: argumentMatch[1],
type: argumentMatch[2] || 'String', // Default to String if type is not specified
required: argumentMatch[3] !== 'false' // Default to true if not specified
};
console.log('Created field:', field); // Debug log
fields.push(field);
}
}
console.log('All fields found:', fields); // Debug log
if (fields.length === 0) {
this.log.error('No fields found in GraphQL operation file');
return null;
}
return fields;
}
async prompting() {
try {
// Fix the path by using the correct base directory
const platformsPath = path.join(process.cwd(), '/frontend/src/app/platforms');
// Check if directory exists first
if (!fs.existsSync(platformsPath)) {
this.log.error('Platforms directory not found at:', platformsPath);
return;
}
const platforms = fs.readdirSync(platformsPath)
.filter(file => fs.statSync(path.join(platformsPath, file)).isDirectory());
if (platforms.length === 0) {
this.log.error('No platforms found in directory:', platformsPath);
return;
}
// First prompts for event type and platform
this.answers = await this.prompt([
{
type: 'list',
name: 'eventType',
message: 'What type of event do you want to add?',
choices: ['click', 'ngSubmit', 'input', 'change'],
default: 'click'
},
{
type: 'list',
name: 'platform',
message: 'Select the platform:',
choices: platforms
}
]);
// Get pages for selected platform
const pagesPath = path.join(platformsPath, this.answers.platform, 'pages');
if (!fs.existsSync(pagesPath)) {
this.log.error('Pages directory not found at:', pagesPath);
return;
}
const pages = fs.readdirSync(pagesPath)
.filter(file => fs.statSync(path.join(pagesPath, file)).isDirectory());
if (pages.length === 0) {
this.log.error('No pages found in directory:', pagesPath);
return;
}
// Always prompt for page selection first
const pageAnswer = await this.prompt([
{
type: 'list',
name: 'page',
message: 'Select the page:',
choices: pages
}
]);
this.answers = { ...this.answers, ...pageAnswer };
// Then handle event-specific prompts
if (this.answers.eventType === 'input') {
const inputAnswers = await this.prompt([
{
type: 'input',
name: 'inputName',
message: 'Enter the name for your input:',
default: 'searchInput'
},
{
type: 'list',
name: 'inputType',
message: 'What type of input?',
choices: ['text', 'number', 'email', 'password', 'search'],
default: 'text'
},
{
type: 'input',
name: 'placeholder',
message: 'Enter placeholder text:',
default: 'Enter text...'
},
{
type: 'input',
name: 'handlerMethod',
message: 'Enter the name of the input handler method:',
default: 'onInputChange'
}
]);
this.answers = { ...this.answers, ...inputAnswers };
} else if (this.answers.eventType === 'change') {
const changeAnswers = await this.prompt([
{
type: 'list',
name: 'elementType',
message: 'What type of element do you want to add?',
choices: ['select', 'checkbox', 'radio'],
default: 'select'
},
{
type: 'input',
name: 'elementName',
message: 'Enter the name for your element:',
default: 'optionSelect'
},
{
type: 'input',
name: 'handlerMethod',
message: 'Enter the name of the change handler method:',
default: 'onSelectionChange'
}
]);
// If select or radio, ask for options
if (changeAnswers.elementType === 'select') {
const optionsAnswer = await this.prompt([
{
type: 'input',
name: 'options',
message: 'Enter options (comma separated, e.g.: option1,option2):',
default: 'option1,option2,option3',
filter: (input) => input.split(',').map(opt => opt.trim()).filter(opt => opt)
}
]);
this.answers = { ...this.answers, ...changeAnswers, ...optionsAnswer };
} else if (changeAnswers.elementType === 'radio') {
const radioAnswer = await this.prompt([
{
type: 'input',
name: 'options',
message: 'Enter radio options (comma separated, e.g.: option1,option2):',
default: 'yes,no',
filter: (input) => input.split(',').map(opt => opt.trim()).filter(opt => opt)
}
]);
this.answers = { ...this.answers, ...changeAnswers, ...radioAnswer };
} else {
this.answers = { ...this.answers, ...changeAnswers };
}
} else if (this.answers.eventType === 'ngSubmit') {
// First get operation type
const operationTypeAnswer = await this.prompt([
{
type: 'list',
name: 'operationType',
message: 'What type of GraphQL operation?',
choices: ['mutation', 'query'],
default: 'mutation'
}
]);
this.answers = { ...this.answers, ...operationTypeAnswer };
// Get available operations based on type
const operations = this._parseGraphQLOperations(this.answers.operationType);
if (operations.length === 0) {
this.log.error(`No ${this.answers.operationType}s found in backend GraphQL types`);
return;
}
// Add operation name selection BEFORE trying to find fields
const operationAnswer = await this.prompt([
{
type: 'list',
name: 'operationName',
message: `Select the GraphQL ${this.answers.operationType}:`,
choices: operations,
default: operations[0]
}
]);
this.answers = { ...this.answers, ...operationAnswer };
// Now we can get fields from the GraphQL operation file
const fields = this._findGraphQLOperation(
this.answers.operationType,
this.answers.operationName
);
if (!fields || fields.length === 0) {
this.log.error('No fields found in GraphQL operation file');
return;
}
// Add console.log to debug the fields
console.log('Fields from GraphQL:', fields);
const formAnswers = await this.prompt([
{
type: 'input',
name: 'formName',
message: 'Enter the name for your form (e.g., login, signup):',
default: 'myForm'
}
]);
// Automatically set fields and their types based on the GraphQL operation
formAnswers.fields = fields.map(f => f.name);
const fieldTypes = {};
fields.forEach(field => {
console.log('Processing field:', field); // Debug log
// Ensure field.type exists and convert to lowercase, default to 'string' if undefined
const fieldType = (field.type || 'String').toLowerCase();
// Map GraphQL types to form field types
switch (fieldType) {
case 'string':
if (field.name.includes('password')) {
fieldTypes[field.name] = 'password';
} else if (field.name.includes('email')) {
fieldTypes[field.name] = 'email';
} else if (field.name.includes('phone') || field.name.includes('telephone')) {
fieldTypes[field.name] = 'tel';
} else {
fieldTypes[field.name] = 'text';
}
break;
case 'boolean':
fieldTypes[field.name] = 'checkbox';
break;
case 'integer':
case 'float':
fieldTypes[field.name] = 'number';
break;
default:
fieldTypes[field.name] = 'text';
}
});
console.log('Field types:', fieldTypes); // Debug log
formAnswers.fieldTypes = fieldTypes;
// Set derived values
formAnswers.responsePath = formAnswers.operationName;
formAnswers.successMessage = `${this._capitalize(formAnswers.operationName)} successful!`;
formAnswers.errorMessage = `${this._capitalize(formAnswers.operationName)} failed. Please check the errors and try again.`;
this.answers = { ...this.answers, ...formAnswers };
// Add validations based on required fields
const fieldValidations = {};
fields.forEach(field => {
fieldValidations[field.name] = field.required ? ['required'] : [];
// Add specific validations based on field type
if (fieldTypes[field.name] === 'email') {
fieldValidations[field.name].push('email');
}
if (field.name === 'password') {
fieldValidations[field.name].push('minLength');
fieldValidations[`${field.name}_minLength`] = '6';
}
});
this.answers.fieldValidations = fieldValidations;
} else {
// For click events
const clickAnswer = await this.prompt([
{
type: 'input',
name: 'actionMethod',
message: 'Enter the name of the click handler method:',
default: 'handleClick'
}
]);
this.answers = { ...this.answers, ...clickAnswer };
}
} catch (error) {
this.log.error('Error during prompting:', error.message);
}
}
writing() {
if (!this.answers) return;
// Check for duplicates in TS file before making any changes
const tsPath = path.join(
process.cwd(),
'frontend/src/app/platforms',
this.answers.platform,
'pages',
this.answers.page,
`${this.answers.page}.page.ts`
);
if (fs.existsSync(tsPath)) {
const tsContent = fs.readFileSync(tsPath, 'utf8');
// Check for duplicates based on event type
if (this.answers.eventType === 'ngSubmit') {
const formRegex = new RegExp(`\\b${this.answers.formName}Form\\s*:\\s*FormGroup\\b`);
const submitRegex = new RegExp(`\\bon${this._capitalize(this.answers.formName)}Submit\\s*\\(`);
if (formRegex.test(tsContent) || submitRegex.test(tsContent)) {
this.log.error(`Form or submit handler for '${this.answers.formName}' already exists in ${tsPath}`);
return; // Exit early without making any changes to either file
}
} else if (this.answers.eventType === 'input') {
const handlerRegex = new RegExp(`\\b${this.answers.handlerMethod}\\s*\\(`);
if (handlerRegex.test(tsContent)) {
this.log.error(`Input handler '${this.answers.handlerMethod}' already exists in ${tsPath}`);
return; // Exit early without making any changes to either file
}
} else if (this.answers.eventType === 'change') {
const handlerRegex = new RegExp(`\\b${this.answers.handlerMethod}\\s*\\(`);
if (handlerRegex.test(tsContent)) {
this.log.error(`Change handler '${this.answers.handlerMethod}' already exists in ${tsPath}`);
return; // Exit early without making any changes to either file
}
}
}
// Only proceed with file modifications if no duplicates were found
const pagePath = path.join(
process.cwd(),
'frontend/src/app/platforms',
this.answers.platform,
'pages',
this.answers.page,
`${this.answers.page}.page.html`
);
if (fs.existsSync(pagePath)) {
let content = fs.readFileSync(pagePath, 'utf8');
if (this.answers.eventType === 'input') {
// Create the input template
const inputTemplate = this._generateInputTemplate();
// Add the input before the closing ion-content tag
const closingTagIndex = content.lastIndexOf('</ion-content>');
if (closingTagIndex !== -1) {
content =
content.slice(0, closingTagIndex) +
inputTemplate +
content.slice(closingTagIndex);
fs.writeFileSync(pagePath, content);
this.log.ok(`Added input to ${pagePath}`);
}
// Update the TypeScript file
this._updateTypeScriptForInput();
} else if (this.answers.eventType === 'change') {
// Create the change element template
const changeTemplate = this._generateChangeTemplate();
// Add the element before the closing ion-content tag
const closingTagIndex = content.lastIndexOf('</ion-content>');
if (closingTagIndex !== -1) {
content =
content.slice(0, closingTagIndex) +
changeTemplate +
content.slice(closingTagIndex);
fs.writeFileSync(pagePath, content);
this.log.ok(`Added ${this.answers.elementType} element to ${pagePath}`);
}
// Update the TypeScript file
this._updateTypeScriptForChange();
} else if (this.answers.eventType === 'ngSubmit') {
// Create the form template
const formTemplate = this._generateFormTemplate();
// Add the form before the closing ion-content tag
const closingTagIndex = content.lastIndexOf('</ion-content>');
if (closingTagIndex !== -1) {
content =
content.slice(0, closingTagIndex) +
formTemplate +
content.slice(closingTagIndex);
fs.writeFileSync(pagePath, content);
this.log.ok(`Added form to ${pagePath}`);
}
// Update the TypeScript file
this._updateTypeScriptForForm();
} else {
// Create the event button
const eventButton = `\n <button (${this.answers.eventType})="${this.answers.actionMethod}()">Submit</button>\n`;
// Add the button before the closing ion-content tag
const closingTagIndex = content.lastIndexOf('</ion-content>');
if (closingTagIndex !== -1) {
content =
content.slice(0, closingTagIndex) +
eventButton +
content.slice(closingTagIndex);
fs.writeFileSync(pagePath, content);
this.log.ok(`Added event button to ${pagePath}`);
}
// Update the TypeScript file to include the method
const tsPath = pagePath.replace('.html', '.ts');
if (fs.existsSync(tsPath)) {
let tsContent = fs.readFileSync(tsPath, 'utf8');
// Add the method before the last closing brace
const lastBraceIndex = tsContent.lastIndexOf('}');
if (lastBraceIndex !== -1) {
const methodCode = `\n ${this.answers.actionMethod}() {\n // Add your action logic here\n console.log('${this.answers.actionMethod} called');\n }\n`;
tsContent =
tsContent.slice(0, lastBraceIndex) +
methodCode +
tsContent.slice(lastBraceIndex);
fs.writeFileSync(tsPath, tsContent);
this.log.ok(`Added event handler to ${tsPath}`);
}
}
}
} else {
this.log.error('Page file not found at:', pagePath);
}
}
// ---------------------------
// FORM-SPECIFIC HELPER METHODS
// ---------------------------
_generateFormTemplate() {
const formName = this.answers.formName;
const capitalizedFormName = this._capitalize(formName);
const formFields = this.answers.fields.map(field => {
const fieldType = this.answers.fieldTypes[field];
let inputElement = '';
switch (fieldType) {
case 'password':
inputElement = `
<ion-input
type="password"
formControlName="${field}"
placeholder="${this._capitalize(field)}"
[class.ion-invalid]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
[class.ion-touched]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
></ion-input>`;
break;
case 'select':
const options = this.answers.fieldTypes[`${field}_options`] || [];
inputElement = `
<ion-select formControlName="${field}">
${options.map(opt => `<ion-select-option value="${opt}">${this._capitalize(opt)}</ion-select-option>`).join('\n ')}
</ion-select>`;
break;
case 'radio':
const radioOptions = this.answers.fieldTypes[`${field}_options`] || [];
inputElement = `
<ion-radio-group formControlName="${field}">
${radioOptions.map(opt => `
<ion-item>
<ion-radio value="${opt}">${this._capitalize(opt)}</ion-radio>
</ion-item>`).join('')}
</ion-radio-group>`;
break;
case 'checkbox':
inputElement = `
<ion-checkbox formControlName="${field}"></ion-checkbox>`;
break;
case 'textarea':
inputElement = `
<ion-textarea
formControlName="${field}"
placeholder="${this._capitalize(field)}"
[class.ion-invalid]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
[class.ion-touched]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
></ion-textarea>`;
break;
default:
inputElement = `
<ion-input
type="${fieldType}"
formControlName="${field}"
placeholder="${this._capitalize(field)}"
[class.ion-invalid]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
[class.ion-touched]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
></ion-input>`;
}
return `
<ion-item class="form-field">
<ion-label position="floating">${this._capitalize(field)}</ion-label>
${inputElement}
</ion-item>
<ion-text color="danger" class="error-message" *ngIf="${formName}IsSubmitted && ${formName}Controls['${field}'].errors">
{{ get${capitalizedFormName}ErrorMessage('${field}') }}
</ion-text>`;
}).join('\n');
return `
<div class="${formName}-container">
<form [formGroup]="${formName}Form" (ngSubmit)="on${capitalizedFormName}Submit()" class="${formName}-form">
${formFields}
<!-- Backend Errors -->
<ion-item lines="none" *ngIf="${formName}BackendErrors?.length" class="status-messages error-messages">
<ion-text color="danger">
<ng-container *ngFor="let error of ${formName}BackendErrors">
<p>{{ error }}</p>
</ng-container>
</ion-text>
</ion-item>
<ion-button
type="submit"
expand="block"
class="submit-button"
[disabled]="${formName}Form.invalid && ${formName}IsSubmitted"
>
Submit
</ion-button>
</form>
</div>`;
}
_processTemplate(template, context) {
// First, handle forEach loops
template = template.replace(
/<% fields\.forEach\(function\(field\) { %>([\s\S]*?)<% }\); %>/g,
(match, fieldTemplate) => {
return context.fields.map(field => {
const fieldType = context.fieldTypes[field] || 'text';
return fieldTemplate
.replace(/<%= field %>/g, field)
.replace(/<%= capitalize\(field\) %>/g, this._capitalize(field))
.replace(/type="text"/g, fieldType === 'password' ? 'type="password"' : `type="${fieldType}"`);
}).join('\n');
}
);
// Then handle simple variable replacements
return template.replace(/<%= ([^%>]+) %>/g, (match, variable) => {
const value = variable.split('.').reduce((obj, key) => obj?.[key], context);
return value !== undefined ? value : '';
});
}
_generateInlineFormTemplate() {
const formName = this.answers.formName;
const capitalizedFormName = this._capitalize(formName);
const formFields = this.answers.fields.map(field => {
const fieldType = this.answers.fieldTypes[field];
let inputElement = '';
switch (fieldType) {
case 'select':
const options = this.answers.fieldTypes[`${field}_options`] || [];
inputElement = `
<ion-select formControlName="${field}">
${options.map(opt => `<ion-select-option value="${opt}">${this._capitalize(opt)}</ion-select-option>`).join('\n ')}
</ion-select>`;
break;
case 'radio':
const radioOptions = this.answers.fieldTypes[`${field}_options`] || [];
inputElement = `
<ion-radio-group formControlName="${field}">
${radioOptions.map(opt => `
<ion-item>
<ion-radio value="${opt}">${this._capitalize(opt)}</ion-radio>
</ion-item>`).join('')}
</ion-radio-group>`;
break;
case 'checkbox':
inputElement = `
<ion-checkbox formControlName="${field}"></ion-checkbox>`;
break;
case 'textarea':
inputElement = `
<ion-textarea
formControlName="${field}"
placeholder="${this._capitalize(field)}"
[class.ion-invalid]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
[class.ion-touched]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
></ion-textarea>`;
break;
default:
inputElement = `
<ion-input
type="${fieldType}"
formControlName="${field}"
placeholder="${this._capitalize(field)}"
[class.ion-invalid]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
[class.ion-touched]="${formName}IsSubmitted && ${formName}Controls['${field}'].errors"
></ion-input>`;
}
return `
<ion-item class="form-field">
<ion-label position="floating">${this._capitalize(field)}</ion-label>
${inputElement}
</ion-item>
<ion-text color="danger" class="error-message" *ngIf="${formName}IsSubmitted && ${formName}Controls['${field}'].errors">
{{ get${capitalizedFormName}ErrorMessage('${field}') }}
</ion-text>`;
}).join('\n');
return `
<div class="${formName}-container">
<form [formGroup]="${formName}Form" (ngSubmit)="on${capitalizedFormName}Submit()" class="${formName}-form">
${formFields}
<!-- Backend Errors -->
<ion-item lines="none" *ngIf="${formName}BackendErrors?.length" class="status-messages error-messages">
<ion-text color="danger">
<ng-container *ngFor="let error of ${formName}BackendErrors">
<p>{{ error }}</p>
</ng-container>
</ion-text>
</ion-item>
<ion-button
type="submit"
expand="block"
class="submit-button"
[disabled]="${formName}Form.invalid && ${formName}IsSubmitted"
>
Submit
</ion-button>
</form>
</div>`;
}
_updateTypeScriptForForm() {
const tsPath = path.join(
process.cwd(),
'frontend/src/app/platforms',
this.answers.platform,
'pages',
this.answers.page,
`${this.answers.page}.page.ts`
);
if (fs.existsSync(tsPath)) {
let tsContent = fs.readFileSync(tsPath, 'utf8');
const formName = this.answers.formName;
const capitalizedFormName = this._capitalize(formName);
// 1. Add required imports if they don't exist
const requiredImports = [
`import { FormBuilder, FormGroup, Validators } from '@angular/forms';`,
`import { ReactiveFormsModule } from '@angular/forms';`
];
let importSection = '';
requiredImports.forEach(imp => {
if (!tsContent.includes(imp)) {
importSection += imp + '\n';
}
});
if (importSection) {
// Add imports after the last existing import
const lastImportIndex = tsContent.lastIndexOf('import ');
if (lastImportIndex !== -1) {
// Insert after the semicolon of the last import
const semicolonIndex = tsContent.indexOf(';', lastImportIndex);
if (semicolonIndex !== -1) {
tsContent =
tsContent.slice(0, semicolonIndex + 1) +
'\n' + importSection +
tsContent.slice(semicolonIndex + 1);
}
} else {
// No import statements found; just prepend them
tsContent = importSection + '\n' + tsContent;
}
}
// 2. Ensure ReactiveFormsModule is in the @Component imports array
const componentMatch = tsContent.match(/ \(\{\s*([\s\S]*?)\}\)/);
if (componentMatch) {
const existingComponentBlock = componentMatch[1];
const importsArrayMatch = existingComponentBlock.match(/imports\s*:\s*\[([\s\S]*?)\]/);
if (importsArrayMatch) {
const existingImports = importsArrayMatch[1].trim();
if (!existingImports.includes('ReactiveFormsModule')) {
const newImports = existingImports ? existingImports + ', ReactiveFormsModule' : 'ReactiveFormsModule';
const updatedImportsBlock = `imports: [${newImports}]`;
const updatedComponentBlock = existingComponentBlock.replace(importsArrayMatch[0], updatedImportsBlock);
tsContent = tsContent.replace(componentMatch[0], ` `);
}
} else {
// No 'imports' array in the @Component, so add one
const updatedComponent = componentMatch[0].replace(
/ \(\{\s*/,
` ;
tsContent = tsContent.replace(componentMatch[0], updatedComponent);
}
}
// 3. Modify (or add) the constructor so it initializes the new form
tsContent = this._modifyConstructorForForm(tsContent, formName);
// 4. Add the form properties and methods (below the constructor, above the last brace)
const lastBraceIndex = tsContent.lastIndexOf('}');
if (lastBraceIndex !== -1) {
// We only add these if they don't already exist
const formCodeSignature = `// ${capitalizedFormName} form properties and methods`;
if (!tsContent.includes(formCodeSignature)) {
const formCode = `
// Common properties
backendErrors: string[] = [];
${formCodeSignature}
${formName}Form!: FormGroup;
${formName}IsSubmitted = false;
${formName}BackendErrors: string[] = [];
get ${formName}Controls() {
return this.${formName}Form.controls;
}
get${capitalizedFormName}ErrorMessage(control: string): string {
if (!this.${formName}IsSubmitted) return '';
const formControl = this.${formName}Controls[control];
if (!formControl || !formControl.errors) return '';
${this._generateErrorMessages()}
return '';
}
async on${capitalizedFormName}Submit() {
this.${formName}IsSubmitted = true;
this.${formName}BackendErrors = [];
if (this.${formName}Form.valid) {
const formData = this.${formName}Form.value;
this.execute${this.answers.operationType === 'mutation' ? 'Mutation' : 'Query'}({
${this.answers.operationType}: ${this._capitalize(this.answers.operationName)}${this._capitalize(this.answers.operationType)},
variables: {
${this.answers.fields.map(field => `${field}: formData.${field}`).join(',\n ')}
},
responsePath: '${this.answers.operationName}',
successMessage: '${this.answers.successMessage}',
errorMessage: '${this.answers.errorMessage}',
onSuccess: () => {
// Add success navigation logic here
console.log('Operation successful');
},
onError: (error) => this.backendErrors = this.errorService.errors
});
}
}
`;
tsContent =
tsContent.slice(0, lastBraceIndex) +
formCode +
'\n' +
tsContent.slice(lastBraceIndex);
}
}
// Before generating the GraphQL operation file, create the proper field objects
const fieldObjects = this.answers.fields.map(fieldName => ({
name: fieldName,
type: this.answers.fieldTypes[fieldName],
required: this.answers.fieldValidations[fieldName]?.includes('required') || false
}));
// Generate the GraphQL operation file and get import info
const operationInfo = this._generateGraphQLOperationFile(
this.answers.operationType,
this.answers.operationName,
fieldObjects // Pass the field objects instead of just field names
);
// Add the import statement at the top of the file
const importStatement = `import { ${operationInfo.operationName} } from '${operationInfo.importPath}';\n`;
// Add import after the last existing import
const lastImportIndex = tsContent.lastIndexOf('import ');
if (lastImportIndex !== -1) {
const semicolonIndex = tsContent.indexOf(';', lastImportIndex);
if (semicolonIndex !== -1) {
tsContent =
tsContent.slice(0, semicolonIndex + 1) +
'\n' + importStatement +
tsContent.slice(semicolonIndex + 1);
}
} else {
// No imports found, add at the beginning
tsContent = importStatement + tsContent;
}
fs.writeFileSync(tsPath, tsContent);
this.log.ok(`Updated TypeScript file at ${tsPath}`);
}
}
/**
* Safely modifies the existing constructor to include the new form initialization,
* without breaking other forms or parameters.
*/
_modifyConstructorForForm(tsContent, formName) {
const formInitSignature = `this.${formName}Form = this.formBuilder.group({`;
// If we already have this form init in the constructor, do nothing
if (tsContent.includes(formInitSignature)) {
return tsContent;
}
// Create the new form initialization snippet
const formInit = `
this.${formName}Form = this.formBuilder.group({
${this._generateFormControls()}
});`;
// Find constructor with a more robust regex that handles malformed constructors
const constructorRegex = /constructor\s*\([^)]*\)\s*{[^}]*}/;
let match = constructorRegex.exec(tsContent);
if (!match) {
// No constructor found, create new one
const classMatch = tsContent.match(/export class\s+[A-Za-z0-9_]+\s*\{/);
if (classMatch) {
const constructorCode = `
constructor(private formBuilder: FormBuilder) {
${formInit.trim()}
}
`;
const insertIndex = tsContent.indexOf('{', classMatch.index) + 1;
return (
tsContent.slice(0, insertIndex) +
constructorCode +
tsContent.slice(insertIndex)
);
}
return tsContent;
}
// Fix malformed constructor by removing extra closing braces
let constructorContent = match[0];
constructorContent = constructorContent.replace(/}(\s*})+$/, '}');
// Extract parameters and body
const paramsMatch = constructorContent.match(/constructor\s*\(([^)]*)\)/);
let constructorParams = paramsMatch ? paramsMatch[1] : '';
// Add formBuilder if not present
if (!constructorParams.includes('formBuilder: FormBuilder')) {
constructorParams = constructorParams.trim();
constructorParams = constructorParams.length > 0
? `${constructorParams}, private formBuilder: FormBuilder`
: 'private formBuilder: FormBuilder';
}
// Extract and clean up constructor body
const bodyMatch = constructorContent.match(/{([^}]*)}/);
let bodyContent = bodyMatch ? bodyMatch[1] : '';
// Clean up body and add new form init
let bodyLines = bodyContent
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (!bodyLines.some(line => line.includes(formInitSignature))) {
bodyLines.push(formInit.trim());
}
// Reconstruct constructor with proper formatting
const newConstructor = `constructor(${constructorParams}) {
${bodyLines.join('\n ')}
}`;
// Replace the old constructor with the new one
return tsContent.replace(match[0], newConstructor);
}
_generateFormControls() {
return this.answers.fields.map(field => {
const validations = this.answers.fieldValidations[field];
if (!validations || validations.length === 0) {
return `${field}: ['']`;
}
const validators = [];
// Add all selected validators
validations.forEach(validation => {
switch (validation) {
case 'required':
validators.push('Validators.required');
break;
case 'email':
validators.push('Validators.email');
break;
case 'minLength':
validators.push(`Validators.minLength(${this.answers.fieldValidations[field + '_minLength']})`);
break;
case 'pattern':
validators.push('Validators.pattern(/^[0-9]{10}$/)');
break;
case 'passwordMatch':
// Handle password match validation separately if needed
break;
}
});
return validators.length > 0
? `${field}: ['', [${validators.join(', ')}]]`
: `${field}: ['']`;
}).join(',\n ');
}
_generateErrorMessages() {
const messages = [];
this.answers.fields.forEach(field => {
const validations = this.answers.fieldValidations[field];
if (!validations || validations.length === 0) {
return;
}
validations.forEach(validation => {
switch (validation) {
case 'required':
messages.push(`if (formControl.errors['required']) return \`\${control} is required\`;`);
break;
case 'email':
messages.push(`if (formControl.errors['email']) return 'Please enter a valid email';`);
break;
case 'minLength':
messages.push(`if (formControl.errors['minlength']) {
const minLength = formControl.errors['minlength'].requiredLength;
return \`\${control} must be at least \${minLength} characters\`;
}`);
break;
case 'pattern':
messages.push(`if (formControl.errors['pattern']) return 'Please enter a valid phone number';`);
break;
case 'passwordMatch':
messages.push(`if (formControl.errors['passwordMismatch']) return 'Passwords do not match';`);
break;
}
});
});
return messages.length > 0 ? messages.join('\n ') : '// No validations required';
}
// ---------------------------
// INPUT-SPECIFIC HELPER METHOD
// ---------------------------
_updateTypeScriptForInput() {
const tsPath = path.join(
process.cwd(),
'frontend/src/app/platforms',
this.answers.platform,
'pages',
this.answers.page,
`${this.answers.page}.page.ts`
);
if (fs.existsSync(tsPath)) {
let tsContent = fs.readFileSync(tsPath, 'utf8');
// Add the handler method (if not present)
const handlerCode = `
${this.answers.handlerMethod}(event: any) {
// Add your input change logic here
console.log('${this.answers.handlerMethod} called', event.target.value);
}
`;
// Insert before the last closing brace
const lastBraceIndex = tsContent.lastIndexOf('}');
if (lastBraceIndex !== -1) {
tsContent =
tsContent.slice(0, lastBraceIndex) +
handlerCode +
'\n' +
tsContent.slice(lastBraceIndex);
}
fs.writeFileSync(tsPath, tsContent);
this.log.ok(`Updated TypeScript file at ${tsPath} with input handler.`);
}
}
_generateInputTemplate() {
return `
<ion-item>
<ion-label position="floating">${this._capitalize(this.answers.inputName)}</ion-label>
<ion-input
type="${this.answers.inputType}"
placeholder="${this.answers.placeholder}"
(${this.answers.eventType})="${this.answers.handlerMethod}($event)">
</ion-input>
</ion-item>\n`;
}
// ----------------------------
// CHANGE-SPECIFIC HELPER METHOD
// ----------------------------
_updateTypeScriptForChange() {
const tsPath = path.join(
process.cwd(),
'frontend/src/app/platforms',
this.answers.platform,
'pages',
this.answers.page,
`${this.answers.page}.page.ts`
);
if (fs.existsSync(tsPath)) {
let tsContent = fs.readFileSync(tsPath, 'utf8');
// Add the handler method (if not present)
const handlerCode = `
${this.answers.handlerMethod}(event: any) {
// Add your change logic here
console.log('${this.answers.handlerMethod} called', event.target.value);
}
`;
// Insert before the last closing brace
const lastBraceIndex = tsContent.lastIndexOf('}');
if (lastBraceIndex !== -1) {
tsContent =
tsContent.slice(0, lastBraceIndex) +
handlerCode +
'\n' +
tsContent.slice(lastBraceIndex);
}
fs.writeFileSync(tsPath, tsContent);
this.log.ok(`Updated TypeScript file at ${tsPath} with change handler.`);
}
}
_generateChangeTemplate() {
if (this.answers.elementType === 'select') {
return `
<ion-item>
<ion-label>${this._capitalize(this.answers.elementName)}</ion-label>
<ion-select (ionChange)="${this.answers.handlerMethod}($event)">
${this.answers.options.map(opt => `<ion-select-option value="${opt}">${this._capitalize(opt)}</ion-select-option>`).join('\n ')}
</ion-select>
</ion-item>\n`;
} else if (this.answers.elementType === 'radio') {
return `
<ion-radio-group (ionChange)="${this.answers.handlerMethod}($event)">
<ion-list-header>
<ion-label>${this._capitalize(this.answers.elementName)}</ion-label>
</ion-list-header>
${this.answers.options.map(opt => `
<ion-item>
<ion-label>${this._capitalize(opt)}</ion-label>
<ion-radio slot="start" value="${opt}"></ion-radio>
</ion-item>`).join('')}
</ion-radio-group>\n`;
} else if (this.answers.elementType === 'checkbox') {
return `
<ion-item>
<ion-label>${this._capitalize(this.answers.elementName)}</ion-label>
<ion-checkbox (ionChange)="${this.answers.handlerMethod}($event)"></ion-checkbox>
</ion-item>\n`;
}
return '';
}
// Utility method
_capitalize(str) {
if (!str || typeof str !== 'string') return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Add this helper method to generate the GraphQL mutation/query file
_generateGraphQLOperationFile(operationType, operationName, fields) {
const platformName = this.answers.platform;
const operationDir = path.join(
process.cwd(),
'frontend/src/app/graphql',
`${operationType}s`,
platformName
);
// Create directories if they don't exist
if (!fs.existsSync(operationDir)) {
fs.mkdirSync(operationDir, { recursive: true });
}
const operationFileName = `${operationName.charAt(0).toLowerCase() + operationName.slice(1)}.${operationType}.ts`;
const operationFilePath = path.join(operationDir, operationFileName);
// Generate the GraphQL operation content
const variables = fields.map(f => `$${f.name}: ${this._getGraphQLType(f)}`).join(', ');
const inputFields = fields.map(f => ` ${f.name}: $${f.name}`).join(',\n');
const returnFields = fields
.filter(f => !f.name.includes('password')) // Exclude password fields from return
.map(f => ` ${f.name}`).join('\n');
const operationContent = `import gql from 'graphql-tag';
export const ${this._capitalize(operationName)}${this._capitalize(operationType)} = gql\`
${operationType} ${operationName}(${variables}) {
${operationName}(input: {
${inputFields}
}) {
data {
id
${returnFields}
}
errors
message
httpStatus
}
}
\`;
`;
// Write the operation file
fs.writeFileSync(operationFilePath, operationContent);
this.log.ok(`Created GraphQL ${operationType} file at ${operationFilePath}`);
return {
importPath: `src/app/graphql/${operationType}s/${platformName}/${operationFileName.replace('.ts', '')}`,
operationName: `${this._capitalize(operationName)}${this._capitalize(operationType)}`
};
}
// Helper to convert form field types to GraphQL types
_getGraphQLType(field) {
let type = 'String';
switch (field.type) {
case 'number':
type = 'Int';
break;
case 'checkbox':
type = 'Boolean';
break;
case 'float':
type = 'Float';
break;
case 'email':
case 'password':
case 'tel':
case 'text':
default:
type = 'String';
}
return field.required ? `${type}!` : type;
}
}