Skip to content

Commit

Permalink
feat(cli): Implement "add entity" command
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Feb 22, 2024
1 parent 795b013 commit ad87531
Show file tree
Hide file tree
Showing 33 changed files with 423 additions and 163 deletions.
38 changes: 38 additions & 0 deletions packages/cli/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import fs from 'fs-extra';
import path from 'path';

// This build script copies all .template.ts files from the "src" directory to the "dist" directory.
// This is necessary because the .template.ts files are used to generate the actual source files.
const templateFiles = findFilesWithSuffix(path.join(__dirname, 'src'), '.template.ts');
for (const file of templateFiles) {
// copy to the equivalent path in the "dist" rather than "src" directory
const relativePath = path.relative(path.join(__dirname, 'src'), file);
const distPath = path.join(__dirname, 'dist', relativePath);
fs.ensureDirSync(path.dirname(distPath));
fs.copyFileSync(file, distPath);
}

function findFilesWithSuffix(directory: string, suffix: string): string[] {
const files: string[] = [];

function traverseDirectory(dir: string) {
const dirContents = fs.readdirSync(dir);

dirContents.forEach(item => {
const itemPath = path.join(dir, item);
const stats = fs.statSync(itemPath);

if (stats.isDirectory()) {
traverseDirectory(itemPath);
} else {
if (item.endsWith(suffix)) {
files.push(itemPath);
}
}
});
}

traverseDirectory(directory);

return files;
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"license": "MIT",
"type": "commonjs",
"scripts": {
"build": "rimraf dist && tsc -p ./tsconfig.cli.json",
"build": "rimraf dist && tsc -p ./tsconfig.cli.json && ts-node ./build.ts",
"watch": "tsc -p ./tsconfig.cli.json --watch",
"ci": "yarn build",
"test": "vitest --config ./vitest.config.ts --run"
Expand Down
12 changes: 1 addition & 11 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Command } from 'commander';

import { registerAddCommand } from './commands/add/add';
import { registerNewCommand } from './commands/new/new';
import { Logger } from './utilities/logger';

const program = new Command();

Expand All @@ -13,17 +12,8 @@ const version = require('../package.json').version;

program
.version(version)
.description('The Vendure CLI')
.option(
'--log-level <logLevel>',
"Log level, either 'silent', 'info', or 'verbose'. Default: 'info'",
'info',
);
.description('The Vendure CLI');

const options = program.opts();
if (options.logLevel) {
Logger.setLogLevel(options.logLevel);
}
registerNewCommand(program);
registerAddCommand(program);

Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/commands/add/add.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cancel, isCancel, select } from '@clack/prompts';
import { cancel, isCancel, log, select } from '@clack/prompts';
import { Command } from 'commander';

import { addEntity } from './entity/add-entity';
import { addUiExtensions } from './ui-extensions/add-ui-extensions';

const cancelledMessage = 'Add feature cancelled.';
Expand All @@ -14,15 +15,22 @@ export function registerAddCommand(program: Command) {
message: 'Which feature would you like to add?',
options: [
{ value: 'uiExtensions', label: 'Set up Admin UI extensions' },
{ value: 'other', label: 'Other' },
{ value: 'entity', label: 'Add a new entity to a plugin' },
],
});
if (isCancel(featureType)) {
cancel(cancelledMessage);
process.exit(0);
}
if (featureType === 'uiExtensions') {
await addUiExtensions();
try {
if (featureType === 'uiExtensions') {
await addUiExtensions();
}
if (featureType === 'entity') {
await addEntity();
}
} catch (e: any) {
log.error(e.message as string);
}
process.exit(0);
});
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/commands/add/entity/add-entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { outro, spinner } from '@clack/prompts';
import { paramCase } from 'change-case';
import path from 'path';

import { getCustomEntityName, selectPluginClass } from '../../../shared/shared-prompts';
import { renderEntity } from '../../../shared/shared-scaffold/entity';
import { createSourceFileFromTemplate, getTsMorphProject } from '../../../utilities/ast-utils';
import { Scaffolder } from '../../../utilities/scaffolder';

import { addEntityToPlugin } from './codemods/add-entity-to-plugin/add-entity-to-plugin';

const cancelledMessage = 'Add entity cancelled';

export interface AddEntityTemplateContext {
entity: {
className: string;
fileName: string;
};
}

export async function addEntity() {
const projectSpinner = spinner();
projectSpinner.start('Analyzing project...');
await new Promise(resolve => setTimeout(resolve, 100));
const project = getTsMorphProject();
projectSpinner.stop('Project analyzed');

const pluginClass = await selectPluginClass(project, cancelledMessage);
const customEntityName = await getCustomEntityName(cancelledMessage);
const context: AddEntityTemplateContext = {
entity: {
className: customEntityName,
fileName: paramCase(customEntityName) + '.entity',
},
};

const entitiesDir = path.join(pluginClass.getSourceFile().getDirectory().getPath(), 'entities');
const entityTemplatePath = path.join(__dirname, 'scaffold/entity.template.ts');
const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
entityFile.move(path.join(entitiesDir, `${context.entity.fileName}.ts`));
entityFile.getClasses()[0].rename(`${context.entity.className}CustomFields`);
entityFile.getClasses()[1].rename(context.entity.className);

addEntityToPlugin(pluginClass, entityFile);

project.saveSync();
outro('✅ Done!');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from 'fs-extra';
import path from 'path';
import { Project } from 'ts-morph';
import { describe, expect, it } from 'vitest';

import { defaultManipulationSettings } from '../../../../../constants';
import { createSourceFileFromTemplate, getPluginClasses } from '../../../../../utilities/ast-utils';

import { addEntityToPlugin } from './add-entity-to-plugin';

describe('addEntityToPlugin', () => {
it('creates entity prop and imports', () => {
const project = new Project({
manipulationSettings: defaultManipulationSettings,
});
project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-entity-prop.fixture.ts'));
const pluginClasses = getPluginClasses(project);
expect(pluginClasses.length).toBe(1);
const entityTemplatePath = path.join(__dirname, '../../scaffold/entity.template.ts');
const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
entityFile.move(path.join(__dirname, 'fixtures/entity.ts'));
addEntityToPlugin(pluginClasses[0], entityFile);

const result = pluginClasses[0].getSourceFile().getText();
const expected = fs.readFileSync(
path.join(__dirname, 'fixtures', 'no-entity-prop.expected'),
'utf-8',
);
expect(result).toBe(expected);
});

it('adds to existing entity prop and imports', () => {
const project = new Project({
manipulationSettings: defaultManipulationSettings,
});
project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'existing-entity-prop.fixture.ts'));
const pluginClasses = getPluginClasses(project);
expect(pluginClasses.length).toBe(1);
const entityTemplatePath = path.join(__dirname, '../../scaffold/entity.template.ts');
const entityFile = createSourceFileFromTemplate(project, entityTemplatePath);
entityFile.move(path.join(__dirname, 'fixtures/entity.ts'));
addEntityToPlugin(pluginClasses[0], entityFile);

const result = pluginClasses[0].getSourceFile().getText();
const expected = fs.readFileSync(
path.join(__dirname, 'fixtures', 'existing-entity-prop.expected'),
'utf-8',
);
expect(result).toBe(expected);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ClassDeclaration, Node, SourceFile, SyntaxKind } from 'ts-morph';

import { addImportsToFile } from '../../../../../utilities/ast-utils';
import { AddEntityTemplateContext } from '../../add-entity';

export function addEntityToPlugin(pluginClass: ClassDeclaration, entitySourceFile: SourceFile) {
const pluginDecorator = pluginClass.getDecorator('VendurePlugin');
if (!pluginDecorator) {
throw new Error('Could not find VendurePlugin decorator');
}
const pluginOptions = pluginDecorator.getArguments()[0];
if (!pluginOptions) {
throw new Error('Could not find VendurePlugin options');
}
const entityClass = entitySourceFile.getClasses().find(c => !c.getName()?.includes('CustomFields'));
if (!entityClass) {
throw new Error('Could not find entity class');
}
const entityClassName = entityClass.getName() as string;
if (Node.isObjectLiteralExpression(pluginOptions)) {
const entityProperty = pluginOptions.getProperty('entities');
if (entityProperty) {
const entitiesArray = entityProperty.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression);
if (entitiesArray) {
entitiesArray.addElement(entityClassName);
}
} else {
pluginOptions.addPropertyAssignment({
name: 'entities',
initializer: `[${entityClassName}]`,
});
}
}

addImportsToFile(pluginClass.getSourceFile(), {
moduleSpecifier: entityClass.getSourceFile(),
namedImports: [entityClassName],
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PluginCommonModule, Type, VendurePlugin, Product } from '@vendure/core';
import { ScaffoldEntity } from './entity';

type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
entities: [Product, ScaffoldEntity],
compatibility: '^2.0.0',
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PluginCommonModule, Type, VendurePlugin, Product } from '@vendure/core';

type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
entities: [Product],
compatibility: '^2.0.0',
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
import { ScaffoldEntity } from './entity';

type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
compatibility: '^2.0.0',
entities: [ScaffoldEntity],
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core';

type PluginInitOptions = any;

@VendurePlugin({
imports: [PluginCommonModule],
compatibility: '^2.0.0',
})
export class TestOnePlugin {
static options: PluginInitOptions;

static init(options: PluginInitOptions): Type<TestOnePlugin> {
this.options = options;
return TestOnePlugin;
}
}
17 changes: 17 additions & 0 deletions packages/cli/src/commands/add/entity/scaffold/entity.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { VendureEntity, DeepPartial, HasCustomFields } from '@vendure/core';
import { Entity, Column } from 'typeorm';

export class ScaffoldEntityCustomFields {}

@Entity()
export class ScaffoldEntity extends VendureEntity implements HasCustomFields {
constructor(input?: DeepPartial<ScaffoldEntity>) {
super(input);
}

@Column()
name: string;

@Column(type => ScaffoldEntityCustomFields)
customFields: ScaffoldEntityCustomFields;
}
Loading

0 comments on commit ad87531

Please sign in to comment.