Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to the "magento-toolbox" extension will be documented in thi

Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.

## [Unreleased]
- Added: Generator command for a ViewModel class

## [1.3.1] - 2025-03-23
- Fixed: Generated plugin class arguments contain an incorrect namespace

Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
"title": "Generate Block",
"category": "Magento Toolbox"
},
{
"command": "magento-toolbox.generateViewModel",
"title": "Generate ViewModel",
"category": "Magento Toolbox"
},
{
"command": "magento-toolbox.indexWorkspace",
"title": "Index Workspace",
Expand Down Expand Up @@ -244,6 +249,10 @@
"command": "magento-toolbox.generateBlock",
"when": "resourcePath =~ /app\\/code\\/.+\\/.+/i"
},
{
"command": "magento-toolbox.generateViewModel",
"when": "resourcePath =~ /app\\/code\\/.+\\/.+/i"
},
{
"command": "magento-toolbox.generateEventsXml",
"when": "resourcePath =~ /app\\/code\\/.+\\/.+/i"
Expand Down
58 changes: 58 additions & 0 deletions src/command/GenerateViewModelCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Command } from 'command/Command';
import ViewModelClassGenerator from 'generator/viewModel/ViewModelClassGenerator';
import ViewModelWizard, { ViewModelWizardData } from 'wizard/ViewModelWizard';
import FileGeneratorManager from 'generator/FileGeneratorManager';
import { Uri, window } from 'vscode';
import Common from 'util/Common';
import WizzardClosedError from 'webview/error/WizzardClosedError';
import IndexManager from 'indexer/IndexManager';
import ModuleIndexer from 'indexer/module/ModuleIndexer';

export default class GenerateViewModelCommand extends Command {
constructor() {
super('magento-toolbox.generateViewModel');
}

public async execute(uri?: Uri): Promise<void> {
const moduleIndex = IndexManager.getIndexData(ModuleIndexer.KEY);
let contextModule: string | undefined;

const contextUri = uri || window.activeTextEditor?.document.uri;

if (moduleIndex && contextUri) {
const module = moduleIndex.getModuleByUri(contextUri);

if (module) {
contextModule = module.name;
}
}

const viewModelWizard = new ViewModelWizard();

let data: ViewModelWizardData;

try {
data = await viewModelWizard.show(contextModule);
} catch (error) {
if (error instanceof WizzardClosedError) {
return;
}

throw error;
}

const manager = new FileGeneratorManager([new ViewModelClassGenerator(data)]);

const workspaceFolder = Common.getActiveWorkspaceFolder();

if (!workspaceFolder) {
window.showErrorMessage('No active workspace folder');
return;
}

await manager.generate(workspaceFolder.uri);
await manager.writeFiles();
await manager.refreshIndex(workspaceFolder);
manager.openFirstFile();
}
}
1 change: 1 addition & 0 deletions src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { default as CopyMagentoPathCommand } from './CopyMagentoPathCommand';
export { default as GenerateXmlCatalogCommand } from './GenerateXmlCatalogCommand';
export { default as GenerateObserverCommand } from './GenerateObserverCommand';
export { default as GenerateBlockCommand } from './GenerateBlockCommand';
export { default as GenerateViewModelCommand } from './GenerateViewModelCommand';
export { default as GenerateEventsXmlCommand } from './GenerateEventsXmlCommand';
export { default as GenerateGraphqlSchemaFileCommand } from './GenerateGraphqlSchemaFile';
export { default as GenerateRoutesXmlFileCommand } from './GenerateRoutesXmlFileCommand';
Expand Down
45 changes: 45 additions & 0 deletions src/generator/viewModel/ViewModelClassGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import FileHeader from 'common/php/FileHeader';
import FileGenerator from 'generator/FileGenerator';
import GeneratedFile from 'generator/GeneratedFile';
import { PhpFile, PsrPrinter } from 'node-php-generator';
import Magento from 'util/Magento';
import { Uri } from 'vscode';
import { ViewModelWizardData } from 'wizard/ViewModelWizard';

export default class ViewModelClassGenerator extends FileGenerator {
private static readonly ARGUMENT_INTERFACE =
'Magento\\Framework\\View\\Element\\Block\\ArgumentInterface';

public constructor(protected data: ViewModelWizardData) {
super();
}

public async generate(workspaceUri: Uri): Promise<GeneratedFile> {
const [vendor, module] = this.data.module.split('_');
const pathParts = this.data.directory.split('/');
const namespaceParts = [vendor, module, ...pathParts];
const moduleDirectory = Magento.getModuleDirectory(vendor, module, workspaceUri);

const header = FileHeader.getHeader(this.data.module);

const phpFile = new PhpFile();
if (header) {
phpFile.addComment(header);
}
phpFile.setStrictTypes(true);

const namespace = phpFile.addNamespace(namespaceParts.join('\\'));
namespace.addUse(ViewModelClassGenerator.ARGUMENT_INTERFACE);

const viewModelClass = namespace.addClass(this.data.className);

viewModelClass.setImplements([ViewModelClassGenerator.ARGUMENT_INTERFACE]);

const printer = new PsrPrinter();

return new GeneratedFile(
Uri.joinPath(moduleDirectory, this.data.directory, `${this.data.className}.php`),
printer.printFile(phpFile)
);
}
}
98 changes: 98 additions & 0 deletions src/test/generator/viewModel/ViewModelClassGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ViewModelWizardData } from 'wizard/ViewModelWizard';
import * as assert from 'assert';
import { Uri } from 'vscode';
import ViewModelClassGenerator from 'generator/viewModel/ViewModelClassGenerator';
import { describe, it, before, afterEach } from 'mocha';
import { setup } from 'test/setup';
import { getReferenceFile, getTestWorkspaceUri } from 'test/util';
import FileHeader from 'common/php/FileHeader';
import sinon from 'sinon';

describe('ViewModelClassGenerator Tests', () => {
const viewModelWizardData: ViewModelWizardData = {
module: 'Foo_Bar',
className: 'TestViewModel',
directory: 'ViewModel',
};

before(async () => {
await setup();
});

afterEach(() => {
sinon.restore();
});

it('should generate viewModel class file', async () => {
// Mock the FileHeader.getHeader method to return a consistent header
sinon.stub(FileHeader, 'getHeader').returns('Foo_Bar');

// Create the generator with test data
const generator = new ViewModelClassGenerator(viewModelWizardData);

// Use a test workspace URI
const workspaceUri = getTestWorkspaceUri();

// Generate the file
const generatedFile = await generator.generate(workspaceUri);

// Get the reference file content
const referenceContent = getReferenceFile('generator/viewModel/TestViewModel.php');

// Compare the generated content with reference
assert.strictEqual(generatedFile.content, referenceContent);
});

it('should generate file in correct location', async () => {
// Create the generator with test data
const generator = new ViewModelClassGenerator(viewModelWizardData);

// Use a test workspace URI
const workspaceUri = getTestWorkspaceUri();

// Generate the file
const generatedFile = await generator.generate(workspaceUri);

// Expected path
const expectedPath = Uri.joinPath(
workspaceUri,
'app/code/Foo/Bar/ViewModel/TestViewModel.php'
).fsPath;

assert.strictEqual(generatedFile.uri.fsPath, expectedPath);
});

it('should generate viewModel class in custom directory', async () => {
// Create test data with custom directory
const customDirectoryData: ViewModelWizardData = {
...viewModelWizardData,
directory: 'ViewModel/Custom/Path',
};

// Mock the FileHeader.getHeader method to return a consistent header
sinon.stub(FileHeader, 'getHeader').returns('Foo_Bar');

// Create the generator with custom directory data
const generator = new ViewModelClassGenerator(customDirectoryData);

// Use a test workspace URI
const workspaceUri = getTestWorkspaceUri();

// Generate the file
const generatedFile = await generator.generate(workspaceUri);

// Get the reference file content for custom directory
const referenceContent = getReferenceFile('generator/viewModel/TestViewModelCustomPath.php');

// Compare the generated content with reference
assert.strictEqual(generatedFile.content, referenceContent);

// Verify file location
const expectedPath = Uri.joinPath(
workspaceUri,
'app/code/Foo/Bar/ViewModel/Custom/Path/TestViewModel.php'
).fsPath;

assert.strictEqual(generatedFile.uri.fsPath, expectedPath);
});
});
63 changes: 63 additions & 0 deletions src/wizard/ViewModelWizard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import IndexManager from 'indexer/IndexManager';
import ModuleIndexer from 'indexer/module/ModuleIndexer';
import { GeneratorWizard } from 'webview/GeneratorWizard';
import { WizardFieldBuilder } from 'webview/WizardFieldBuilder';
import { WizardFormBuilder } from 'webview/WizardFormBuilder';
import { WizardTabBuilder } from 'webview/WizardTabBuilder';

export interface ViewModelWizardData {
module: string;
className: string;
directory: string;
}

export default class ViewModelWizard extends GeneratorWizard {
public async show(contextModule?: string): Promise<ViewModelWizardData> {
const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY);

if (!moduleIndexData) {
throw new Error('Module index data not found');
}

const modules = moduleIndexData.getModuleOptions(m => m.location === 'app');

const builder = new WizardFormBuilder();

builder.setTitle('Generate a new ViewModel');
builder.setDescription('Generates a new Magento2 ViewModel class.');

const tab = new WizardTabBuilder();
tab.setId('viewModel');
tab.setTitle('ViewModel');

tab.addField(
WizardFieldBuilder.select('module', 'Module*')
.setOptions(modules)
.setInitialValue(contextModule || modules[0].value)
.build()
);

tab.addField(
WizardFieldBuilder.text('className', 'Class Name*')
.setPlaceholder('ViewModel class name')
.build()
);

tab.addField(
WizardFieldBuilder.text('directory', 'Directory*')
.setPlaceholder('ViewModel/Path')
.setInitialValue('ViewModel')
.build()
);

builder.addTab(tab.build());

builder.addValidation('module', 'required');
builder.addValidation('className', 'required|min:1');
builder.addValidation('directory', 'required|min:1');

const data = await this.openWizard<ViewModelWizardData>(builder.build());

return data;
}
}
15 changes: 15 additions & 0 deletions test-resources/reference/generator/viewModel/TestViewModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

/**
* Foo_Bar
*/

declare(strict_types=1);

namespace Foo\Bar\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;

class TestViewModel implements ArgumentInterface
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

/**
* Foo_Bar
*/

declare(strict_types=1);

namespace Foo\Bar\ViewModel\Custom\Path;

use Magento\Framework\View\Element\Block\ArgumentInterface;

class TestViewModel implements ArgumentInterface
{
}