@department-of-veterans-affairs/generator-vets-website
Version:
Generate a React app for vets-website
380 lines (349 loc) • 11.7 kB
JavaScript
/**
* Shared field definitions for all generators
* Single source of truth for all field configurations
*/
import path from 'node:path';
import { v4 as uuid } from 'uuid';
import {
isInvalidFolderName,
isInvalidEntryName,
isInvalidSlackGroup,
} from '../utils/validation.js';
import { folderNameFilter } from '../utils/filters.js';
import { store } from './store.js';
/**
* Check if a CLI argument was provided in either camelCase or kebab-case format
* @param {string} fieldName - The field name to check for
* @param {Object} generator - The Yeoman generator instance
* @returns {boolean} - True if the argument was provided
*/
export function hasCliArgument(fieldName, generator) {
// Convert camelCase to kebab-case
const kebabCase = fieldName.replace(/([A-Z])/g, '-$1').toLowerCase();
// Check if argument exists in process.argv (supports both --arg and --arg=value formats)
const hasCamelCase = process.argv.some((arg) => arg.startsWith(`--${fieldName}`));
const hasKebabCase = process.argv.some((arg) => arg.startsWith(`--${kebabCase}`));
// Also check if the option was parsed and is available in generator.options
const hasOptionValue = generator.options && generator.options[fieldName] !== undefined;
return hasCamelCase || hasKebabCase || hasOptionValue;
}
export const appFields = {
appName: {
type: 'input',
message:
"What's the name of your application? This will be the default page title. Examples: '21P-530 Burials benefits form' or 'GI Bill School Feedback Tool'",
default: 'A New Form',
required: true,
validate: (input) => (input ? true : 'Application name is required'),
},
folderName: {
type: 'input',
message:
"What folder in `src/applications/` should your app live in? This can be a subfolder. Examples: 'burials' or 'edu-benefits/0993'",
required: true,
validate: isInvalidFolderName,
filter: folderNameFilter,
default(answers) {
// First try to get from current answers, then fall back to store
const appName = (answers && answers.appName) || store.getValue('appName');
if (appName) {
return appName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
return undefined;
},
},
entryName: {
type: 'input',
message:
"What should be the name of your app's entry bundle? Examples: '0993-edu-benefits' or 'feedback-tool'",
required: true,
validate: isInvalidEntryName,
default(answers) {
// First try to get from current answers, then fall back to store
const appName = (answers && answers.appName) || store.getValue('appName');
if (appName) {
return appName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
return undefined;
},
},
rootUrl: {
type: 'input',
message:
"What's the root url of your application? It should start with a forward slash and be all lowercase. Examples: '/burials-530/' or '/education/0993/'",
required: true,
validate: (input) => (input ? true : 'Root URL is required'),
default(answers) {
// First try to get from current answers, then fall back to store
const appName = (answers && answers.appName) || store.getValue('appName');
if (appName) {
const kebabName = appName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return `/${kebabName}`;
}
return undefined;
},
},
isForm: {
type: 'Boolean',
message: 'Is this a form application?',
default: true,
required: true,
},
contentRepoLocation: {
type: 'input',
message:
'Where can I find the vagov-content repo? This path can be absolute or relative to vets-website.',
default() {
const currentDir = process.cwd();
const parentDir = path.dirname(currentDir);
return path.join(parentDir, 'vagov-content');
},
},
slackGroup: {
type: 'input',
message:
"What Slack user group should be notified for CI failures on the `main` branch? Example: '@vaos-fe-dev'",
default: 'none',
validate: isInvalidSlackGroup,
},
productId: {
type: 'input',
message: 'What is the product ID for the registry?',
default() {
return uuid();
},
},
};
// Form-specific field definitions
export const formFields = {
formNumber: {
type: 'input',
message: "What's your form number? Examples: '22-0993' or '21P-530'",
required: true,
validate: (input) => (input ? true : 'Form number is required'),
},
benefitDescription: {
type: 'input',
message:
"What's the benefit description for this form? Examples: 'education benefits' or 'disability claims increase'",
default: 'benefits',
},
trackingPrefix: {
type: 'input',
message:
"What's the Google Analytics event prefix that you want to use? Examples: 'burials-530-' or 'edu-0993-'",
default() {
// Get entryName from the store (set by app generator)
const entryName = store.getValue('entryName');
if (entryName) {
return `${entryName}-`;
}
// Fallback to getting from appName in store
const appName = store.getValue('appName');
if (appName) {
const kebabName = appName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return `${kebabName}-`;
}
return 'form-';
},
},
respondentBurden: {
type: 'input',
message: "What's the respondent burden of this form in minutes?",
default: '30',
validate: (input) => (input ? true : 'Respondent burden is required'),
},
ombNumber: {
type: 'input',
message: "What's the OMB control number for this form? Example: '2900-0797'",
default: '1234-5678',
},
expirationDate: {
type: 'input',
message:
"What's the OMB expiration date (in M/D/YYYY format) for this form? Example: '1/31/2019'",
default() {
const today = new Date();
const nextYear = new Date(
today.getFullYear() + 1,
today.getMonth(),
today.getDate(),
);
return `${nextYear.getMonth() + 1}/${nextYear.getDate()}/${nextYear.getFullYear()}`;
},
},
usesVetsJsonSchema: {
type: 'Boolean',
message:
'Does this form use vets-json-schema? (JSON schemas defined in separate repository)',
default: false,
},
usesMinimalHeader: {
type: 'Boolean',
message: 'Use minimal header (minimal form flow) pattern?',
default: true,
},
addToMyVaSip: {
type: 'Boolean',
message:
'Should this form appear on the My VA dashboard for users with in-progress applications?',
default: true,
},
templateType: {
type: 'list',
promptType: 'list',
message: 'Which form template would you like to start with?',
choices: [
{
name: 'WITH_1_PAGE: A form with 1 page - name and date of birth',
value: 'WITH_1_PAGE',
},
{
name: 'WITH_4_PAGES: A form with 4 pages - name and date of birth, identification information, mailing address, and phone and email',
value: 'WITH_4_PAGES',
},
{
name: 'FORM_ENGINE: A form from Drupal using the shared Form Engine',
value: 'FORM_ENGINE',
},
],
default: 'WITH_1_PAGE',
},
};
/**
* Get field definitions for a specific generator type
* @param {string} type - 'app' or 'form' or 'all'
* @returns {Object} Field definitions
*/
export function getFieldDefinitions(type = 'app') {
switch (type) {
case 'app':
return appFields;
case 'form':
return formFields;
case 'all':
return { ...appFields, ...formFields };
default:
throw new Error(`Unknown field type: ${type}. Use 'app', 'form', or 'all'`);
}
}
/**
* Get field definitions based on context (isForm flag)
* @param {boolean} isForm - Whether this is a form application
* @returns {Object} Appropriate field definitions
*/
export function getContextualFieldDefinitions(isForm) {
return isForm ? getFieldDefinitions('all') : getFieldDefinitions('app');
}
/**
* Gets required field names based on form configuration (for prompt validation)
*/
export function getRequiredFields(isForm) {
const fields = getContextualFieldDefinitions(isForm);
return Object.keys(fields).filter((key) => fields[key].required);
}
/**
* Gets the list of required CLI arguments for non-interactive mode (strict validation)
* In non-interactive mode, all these fields must be explicitly provided via CLI arguments
* @param {Object} options - The CLI options object
* @returns {Array} - Array of required field names
*/
export function getNonInteractiveRequiredFields(options) {
// Core required fields for all apps in non-interactive mode
const requiredFields = ['appName', 'folderName', 'entryName', 'rootUrl', 'isForm'];
// Additional required fields for forms only
if (options.isForm === true || options.isForm === 'true') {
requiredFields.push(
'formNumber',
'benefitDescription',
'ombNumber',
'expirationDate',
);
}
return requiredFields;
}
/**
* Gets the list of required CLI arguments for legacy compatibility
* @param {Object} options - The CLI options object
* @returns {Array} - Array of required field names
*/
export function getLegacyRequiredFields(options) {
// Core required fields for all apps (based on consumer requirements)
const requiredFields = [
'appName',
'folderName',
'entryName',
'rootUrl',
'isForm', // Internal requirement to determine if additional fields needed
];
// Additional required fields for forms only
if (options.isForm === true || options.isForm === 'true') {
requiredFields.push(
'formNumber',
'benefitDescription',
'ombNumber',
'expirationDate',
'templateType',
);
}
return requiredFields;
}
/**
* Generate prompts from field definitions for Yeoman generator
* @param {Object} generator - The Yeoman generator instance
* @param {Object} fieldDefinitions - Field definitions object
* @param {Array} fieldNames - Optional array of field names to process
*/
export function generatePrompts(generator, fieldDefinitions, fieldNames = null) {
const fieldsToProcess = fieldNames || Object.keys(fieldDefinitions);
return fieldsToProcess
.map((fieldName) => {
const field = fieldDefinitions[fieldName];
if (!field || !field.message) return null;
const prompt = {
type: field.promptType || (field.type === 'Boolean' ? 'confirm' : 'input'),
name: fieldName,
message: field.message,
};
// Add optional properties
if (field.default !== undefined) {
// If default is a function that expects (props, generator), bind the generator
if (typeof field.default === 'function') {
prompt.default = (answers) => field.default(answers, generator);
} else {
prompt.default = field.default;
}
}
if (field.choices !== undefined) {
prompt.choices = field.choices;
}
if (field.validate !== undefined) {
prompt.validate = field.validate;
}
if (field.filter !== undefined) {
prompt.filter = field.filter;
}
if (field.when !== undefined) {
// If when is a function that expects (props, generator), bind the generator
if (typeof field.when === 'function') {
prompt.when = (answers) => field.when(answers, generator);
} else {
prompt.when = field.when;
}
}
return prompt;
})
.filter(Boolean);
}