Part III - Generating the front-end

In this part of the tutorial, you will build an Angular application that uses the API from part II as its data source. You will generate an application with 2 type of screens: one that lists all items, and one that contains a form for editing an item. At the end of this part, you have generated:

  • TypeScript typings for the REST resources that you generated in part II.
  • Angular services that consume the API that you generated in part II.
  • Angular components for master/detail views, including views.
  • Angular routing and some bootstrapping for these components.

The front-end will use Bootstrap for styling, and is based on the Bootstrap Dashboard example.

If you skipped part II: you can run the API by running (hit CTRL+F5). The API endpoint http://localhost:5000/api/book should return some test book data.

Generating TypeScript typings and services

Because the entire bookstore application is model-based, it's easy to take advantage of TypeScript's strongly-typed nature and generate typings for the resources that are exposed through the API. Open file CodeGen/app/resources.template.ts and paste the following code:

import * as elements from '@yellicode/elements';
import { UpperToLowerCamelCaseTransform, RenameTargets } from '@yellicode/elements';
import { TextWriter } from '@yellicode/core';  
import { Generator } from '@yellicode/templating';  
import { TypeScriptWriter } from '@yellicode/typescript';
import * as utils from './lib/utils';

const outputDirectory = '../../app/src/services';

/**
 * Writes all TypeScript properties of a REST resource. 
 */
const resourceTemplate = (writer: TypeScriptWriter, c: elements.Class): void => {
    // Write a property for each attribute. It depends on the original attribute type what to write here.
    c.ownedAttributes.forEach(att => {
        writer.writeLine();        
        if (!elements.isClass(att.type)) {
            // A primitive type: write the property as-is.
            writer.writeProperty(att);
        }
        else if (!att.isMultivalued()) {
            // A complex type, single-valued, create a foreign key (ResourceInfo) property instead.
            const optionalModifier = att.isOptional() ? '?' : '';
            writer.writeJsDocDescription(att.ownedComments);
            writer.writeLine(`public ${att.name}${optionalModifier}: ResourceInfo;`);
        }
        else {
            // A complex type, multi-valued, create a foreign key (ResourceInfo) collection property instead.
            writer.writeJsDocDescription(att.ownedComments);
            writer.writeLine(`public ${att.name}: ResourceInfo[];`);
        }
    });
    // Add a displayName property
    writer.writeLine();
    writer.writeJsDocDescription(`Gets the display name of this ${c.name}. This property is generated.`);
    writer.writeLine(`public displayName: string;`);
}

// Since in JSON, we have lowerCamelCase properties and our model is in UpperCamelCase, transform it
const modelTransform = new UpperToLowerCamelCaseTransform(RenameTargets.allMembers);

Generator.generateFromModel({ outputFile: `${outputDirectory}/resources.ts`, modelTransform: modelTransform }, (textWriter: TextWriter, model: elements.Model) => {
    const writer = new TypeScriptWriter(textWriter);
    utils.writeCodeGenerationWarning(writer);
    writer.writeLine('import { ResourceInfo } from "./resource-info";');
    model.getAllClasses().forEach(c => {
        writer.writeLine(); // insert a blank line
        writer.writeClassBlock(c, (writer) => { resourceTemplate(writer, c) }, { export: true, declare: true });
    });
});

If you followed part II, you will notice a lot of similarity with the resources template that your created for the C# resources. It loops over the classes in the model and generates code for each one. One obvious difference is that now the TypeScriptWriter is used from the @yellicode/typescript package. Also, where in the C# template you renamed the class with a 'Resource' suffix, this is not needed now (although you could). Last but not least, this template deals with casing: by JSON convention, the API returns JSON properties in lowerCamelCase and our model classes have UpperCamelCase properties . Therefore, the template transforms the properties to lowerCamelCase using the UpperToLowerCamelCaseTransform class.

Generating the data services is very straightforward: the template for this can be found in CodeGen/app/data-services.template.ts. All it does is create the basic CRUD functions for each class, using the correct types and URL's.

Generating the Angular components

Like in many applications, the layout of each screen is often quite similar, only their underlying data varies. In this step, you will generate a standardised UI for each resource: a list view (a HTML table) and a form. Most of the code for this is already included in the project, you will only need to create the template to glue it all together.

Open file CodeGen/app/components.template.ts and insert the following code:

import { TextWriter, NameUtility } from '@yellicode/core';
import { Generator } from '@yellicode/templating';  
import * as elements from '@yellicode/elements';
import { UpperToLowerCamelCaseTransform, RenameTargets } from '@yellicode/elements';
import { TypeScriptWriter } from '@yellicode/typescript';
import { HtmlWriter } from '@yellicode/html';
import { PathUtility } from '../path-utility';
import { tableTemplate } from './table.template';
import { formTemplate } from './form.template';
import * as utils from './lib/utils';

// Some sub-templates that generate the actual components
import { indexComponentTemplate } from './index-component.template';
import { detailsComponentTemplate } from './details-component.template';

// Output is going to the app project
const outputDirectory = '../../app/src/components';

// We get lowerCamelCase properties from the API and our model is in UpperCamelCase, transform them
const modelTransform = new UpperToLowerCamelCaseTransform(RenameTargets.allMembers);

Generator.getModel({ modelTransform: modelTransform }).then((model: elements.Model) => {
    // Get all classes in the model and create a component for each class
    model.getAllClasses().forEach(c => {
        const basePathSegment = PathUtility.createPathSegment(c); // This creates a kebab-case name
        const indexFileName = `${basePathSegment}-index`; // e.g. book-index
        const detailsFileName = `${basePathSegment}-details`; // e.g. book-details        

        // 1: Generate the index component and view         
        const itemsPropertyName = `${NameUtility.upperToLowerCamelCase(c.name)}Items`; // e.g. 'bookItems'        
        // 1A: The component, e.g. book-index.component.ts
        Generator.generate({ outputFile: `${outputDirectory}/${indexFileName}.component.ts` }, (tw: TextWriter) => {
            const writer = new TypeScriptWriter(tw);
            utils.writeCodeGenerationWarning(writer);
            indexComponentTemplate(writer, c, itemsPropertyName);
        });
        // 1B: The view, e.g. book-index.html
        Generator.generate({ outputFile: `${outputDirectory}/${indexFileName}.html` }, (tw: TextWriter) => {
            const writer: HtmlWriter = new HtmlWriter(tw);
            // Make a 'New' button on the top
            const createUrl = `/${basePathSegment}/create`;
            writer.writeElement('a', { classNames: 'btn btn-primary float-right', attributes: { href: '#', routerLink: createUrl } }, `New ${c.name}`);
            writer.writeElement('h2', {}, c.name); // title
            tableTemplate(writer, c, itemsPropertyName); // data table
        });

        // 2: Generate the details component and view        
        // 2A: The component, e.g. book-details.component.ts
        Generator.generate({ outputFile: `${outputDirectory}/${detailsFileName}.component.ts` }, (tw: TextWriter) => {
            const writer = new TypeScriptWriter(tw);
            utils.writeCodeGenerationWarning(writer);
            detailsComponentTemplate(writer, c);
        });
        // 2B: The view, e.g. book-details.html
        Generator.generate({ outputFile: `${outputDirectory}/${detailsFileName}.html` }, (tw: TextWriter) => {
            const writer: HtmlWriter = new HtmlWriter(tw);
            writer.writeElement('div', { classNames: 'details', attributes: { '*ngIf': 'model' } }, () => {
                writer.writeElement('h2', {}, `{{model.displayName}}`); // title
                formTemplate(writer, c);
            })
        });
    });
});

Ok, that's quite a bit of code. But it does generate a fully functional component structure, including HTML tables, forms and two-way data-binding. This template introduces a new code writer, the HtmlWriter from the @yellicode/html NPM package. This writer has some helpful functions for generationg HTML elements and attributes. Most of the work is done by the included sub-templates, just have a look at them, they should be pretty easy to understand.

Before we start the code generator, there is one more template left to discuss: before using a component, Angular requires that it is declared in the global appmodule. Since you will already generate the components, why not generate the declarations as well? And now we are at it: you can generate Angular routes too! Insert the following code into CodeGen/app/components.config.template.ts:

import * as elements from '@yellicode/elements'; 
import { TextWriter } from '@yellicode/core';  
import { Generator } from '@yellicode/templating';  
import { TypeScriptWriter } from '@yellicode/typescript';
import { PathUtility } from '../path-utility';
import { AngularWriter } from '@yellicode/angular';
import * as utils from './lib/utils';

const outputDirectory = '../../app/src';

Generator.generateFromModel({ outputFile: `${outputDirectory}/app-components.config.ts`}, (textWriter: TextWriter, model: elements.Model) => {
    const writer = new TypeScriptWriter(textWriter); 
    const components  = model.getAllClasses();
    
    utils.writeCodeGenerationWarning(writer);
    // Write imports
    components.forEach(c => {      
        const pathSegment = PathUtility.createPathSegment(c);
        writer.writeLine(`import { ${c.name}IndexComponent } from './components/${pathSegment}-index.component';`);
        writer.writeLine(`import { ${c.name}DetailsComponent } from './components/${pathSegment}-details.component';`);
    });
    writer.writeLine();

    // Declarations
    writer.writeLine('export const declarations = [')    
    writer.increaseIndent();
    const componentNames: string[] = [];
    componentNames.push(...components.map(c => `${c.name}IndexComponent`));
    componentNames.push(...components.map(c => `${c.name}DetailsComponent`));
    writer.writeLines(componentNames, ',');        
    writer.decreaseIndent();    
    writer.writeLine(']');
    writer.writeLine();

    // Routes
    writer.writeLine('export const routes = [')    
    writer.increaseIndent();
    components.forEach(c => {
        const basePath = PathUtility.createPathSegment(c);
        AngularWriter.writeRoute(writer, {path: basePath, componentName: `${c.name}IndexComponent`});
        AngularWriter.writeRoute(writer, {path: `${basePath}/edit/:id`, componentName: `${c.name}DetailsComponent`});
        AngularWriter.writeRoute(writer, {path: `${basePath}/create`, componentName: `${c.name}DetailsComponent`});
    });    
    writer.decreaseIndent();    
    writer.writeLine(']');   
});

Generating the code

You are almost ready to run ... first add a some lines to the Yellicode configuration file CodeGen/codegenconfig.json and save it:

{
    "templateFile": "./app/resources.template.ts",
    "modelFile":"./bookstore"
},
{
    "templateFile": "./app/data-services.template.ts",
    "modelFile":"./bookstore"
},       
{
    "templateFile": "./app/components.template.ts",
    "modelFile":"./bookstore"
},
{
    "templateFile": "./app/components.config.template.ts",
    "modelFile":"./bookstore"
},
{
    "templateFile": "./app/side-nav.template.ts",
    "modelFile":"./bookstore"
}  

This adds all templates we discussed in this part (ok, and a template that generates the side navigation - just because we can -:). If the Yellicode CLI is not still running since part II, start it now by opening the CodeGen folder in the terminal and entering:

yellicode --watch

Wait a few seconds for the application to be generated. If the web server is still running since you started it in the introduction, you should immediately see your browser window refresh and show your fully functional, generated bookstore app! If not, open the root App folder in the terminal and run the npm start command again.

Making changes to the model

So, how to make changes to the bookstore model and let them appear in the UI? Well, lets's add a Description property to the book class:

  1. Open the bookstore model (CodeGen/bookstore.ymn) in Yellicode Modeler.
  2. Open the Book class and add a new attribute by clicking the + sign in the attributes table and name it Description.
  3. As Type, choose integer and choose public Visibility.
  4. Press CLTRL+S to save the model.

That's it! Your code is being updated and the app window will refresh and show the updated UI. If nothing happens, make sure that the Yellicode CLI and the web app are still running.

Please note that, in order have the API work with the new property, you will need to restart it.

Coming next...

You have just generated your first fully functional Angular application, including a REST API. But, looking at our generated bookstore UI, you might have noticed the following inconveniences:

  • Technical property names are used as labels. This is not very user-friendly.
  • The app contains multiple text fields. But not all text fields are the same: some fit in a single-line text box, but some don't. And what if we need, say, an email input?
  • And there might be more that you would like to customize, while still keeping your app model-based.

This is where profiles come into play. Using a profile, you can extend your own model with custom meta data and use this meta data in your code generator. This will be the goal of part IV.

Continue to Part IV - Applying Profiles »