Your first project

Follow along with these steps to create your first Typescene Web application project.

Source code: The code for this example project is available on GitHub: typescene/first-project. A JavaScript-only (no TypeScript) version is available as first-project-js.

View online: The final output can be seen in action here.

See also: For a more complex example, check out the RealWorld front-end implementation in this repo..

Setup

Before getting started with Typescene, make sure you’re familiar with JavaScript. Knowledge of TypeScript would also be very useful, especially to understand the reference documentation and autocomplete options, although you could definitely use Typescene with ‘plain’ modern JavaScript if you don’t want to use TypeScript for your own code. To learn Typescript, start with the 5-minute tutorial on the documentation website.

Make sure NodeJS is installed on your computer, and the npm command has been added to your PATH. To test this, enter npm -v in a terminal window, which should show you the current version of NPM.

Source files can be edited using any editor that can save plain text files, but an IDE or editor that is optimized for TypeScript can speed up your workflow a lot. Microsoft VS Code is an excellent choice.

You can also install the Typescene Snippets extension in Visual Studio Code for a quicker way to insert common class definitions and other code.

Creating a package

While you could easily create an NPM package from scratch if you wanted to, it’s much faster to use the create-typescene-webapp utility. This creates a folder, adds source files, and initializes NPM dependencies in one go.

Run the following command in a Terminal window (Mac OS/Linux) or Command Prompt (Windows):

npx create-typescene-webapp my-project

Add the --js option to use plain JavaScript (ES6) instead of TypeScript, and use --git to initialize a Git repository automatically.

Testing your setup

To test the ‘hello world’ app that create-typescene-webapp just added to your project folder, start the Webpack development server from your terminal:

cd my-project
npm run start

Webpack starts its server at http://localhost:8080/ and opens your default browser. In one command, Webpack has compiled our simple app along with its own dev-server and hot module replacement code, and we are now ready to get started.

‘Todo’ Service and model

Let’s build a simple ‘todo’ app that lets you enter tasks, mark them as done, and remove them. At a high level, we’re going to create the following components:

  • A service that keeps track of the data model,
  • An activity that displays the ‘main’ screen of the app,
  • A view for the main screen.

We’ll start with the service. Since the minimal sample code we copied didn’t include any services, we’ll need to add a folder to our project. It’s good practice to keep services together in a separate services folder, away from activities and views, since services are often used across multiple activities anyway.

To represent our todo items, we’ll extend the ManagedRecord class. Instances of this class can be kept in a ManagedList object which is in turn referenced from the TodoService itself, as managed child objects. This way, we can simply ‘destroy’ our todo items to get them removed from the list.

Create the src/services/TodoService.ts file in your project (or use .js if you’re not using TypeScript), and add the following code:

import { CHANGE, managedChild, ManagedList,
  ManagedRecord, ManagedService } from "typescene";

/** Represents a single to-do item */
export class TodoItem extends ManagedRecord {
  constructor(public text: string) {
    super();
  }
  complete?: boolean;
}

export default class TodoService extends ManagedService {
  name = "App.Todo";

  /** Current list of todo items */
  @managedChild
  readonly items = new ManagedList()
    .restrict(TodoItem)
    .propagateEvents();

  nRemaining = 0;
  nCompleted = 0;

  addItem(text: string) {
    if (text) this.items.add(new TodoItem(text));
  }

  removeCompleted() {
    this.items.forEach(it => {
      if (it.complete) this.items.remove(it);
    });
  }
}
TodoService.observe(class {
  constructor(public readonly svc: TodoService) { }

  // called when (any of) the items change(s):
  onItemsChangeAsync() {
    let nCompleted = this.svc.items
      .pluck("complete")
      .filter(b => b).length;
    let total = this.svc.items.count;
    this.svc.nCompleted = nCompleted;
    this.svc.nRemaining = total - nCompleted;
    this.svc.emit(CHANGE);
  }
})

Note that we’re exposing the items property as a public property on the TodoService directly. Usually in object-oriented programming we’ll only want to expose functionality through getters and setters, however since we’re restricting the list to instances of TodoItem anyway, this is relatively safe and it saves us from having to create methods for all of the list operations, while still being able to watch for changes and guarantee consistency.

Now we need to make sure that we can actually use this service from our activity. We’ll register an instance of this class in the main file for our app — add the following lines to src/app.ts:

import TodoService from "./services/TodoService";
new TodoService().register();

Main Activity

Typescene applications are really just collections of activities, which are activated and deactivated as the user navigates around the app. Activities represent the overall application state, and contain most of the logic that runs in response to user actions. In the case of our ‘todo’ app, we’ll only need one activity.

The code for a MainActivity class is already included in the minimal code we copied from the Web app package, but that doesn’t do much at all.

Find the src/activities/main/activity.ts file, and remove the code for the onManagedStateActiveAsync method, since we don’t need this handler for now. Add the following lines instead:

@service("App.Todo")
todo?: TodoService;

Note: The code above requires the symbols service and TodoService to be imported at the top of the file. If you’re using Visual Studio Code, a lightbulb shows up when you select these IDs — click the icon and you’ll find an option to add imports automatically. Alternatively, read on for a full listing of the finished activity source file.

This seems like a lot of code to refer to the TodoService we created earlier, but every part has its own purpose here:

  1. The @service decorator turns the ordinary todo property into a service property, which keeps the property value up to date as services are registered, destroyed, or replaced.
  2. "App.Todo" is the exact name of the service that we’re looking for, provided to the decorator as a string parameter.
  3. todo is the property name that we’re adding to the activity class.
  4. ?: TodoService is a TypeScript type annotation that tells the compiler that our todo property refers to an instance of our TodoService class or may be set to undefined (since we’re not actually setting the property directly; however in practice the service is already registered when the activity is created, so the property has a value all the time).

To accept entry of new tasks using a text field without having to listen for changes to the text field itself, we’ll use a form context controller in our UI. The controller binds to a managed object on the activity, and provides a reference to this ‘form context’ to all child components. Let’s create the form context and store it in a formInput property:

@managedChild
formInput = ManagedRecord.create({
  newTask: ""
});

Furthermore, the activity needs to be able to handle events that come in from the view. Let’s add some handler methods for adding a task, removing all completed tasks, and toggling the state of a task (which listens for a UIListCellAdapterEvent, a special type of event that’s emitted by a list item cell and includes a reference to the list item’s object — the managed object that’s represented by the cell that generated the original event).

Here’s the final version of our src/activities/main/activity.ts file:

import { CHANGE, managedChild, ManagedRecord,
  PageViewActivity, service,
  UIListCellAdapterEvent } from "typescene";
import TodoService, { TodoItem } from "../../services/TodoService";
import view from "./view";

export default class MainActivity
  extends PageViewActivity.with(view) {
  path = "/";

  @service("App.Todo")
  todo?: TodoService;

  /** Form context for the 'add a task' text field */
  @managedChild
  formInput = ManagedRecord.create({
    newTask: ""
  });

  // event handlers:

  addTask() {
    this.todo!.addItem(this.formInput.newTask);
    this.formInput.newTask = "";
    this.formInput.emit(CHANGE);
  }

  toggleTask(e: UIListCellAdapterEvent<TodoItem>) {
    if (e.object instanceof TodoItem) {
      e.object.complete = !e.object.complete;
      e.object.emit(CHANGE);
    }
  }

  removeCompleted() {
    this.todo!.removeCompleted();
  }
}

View

Now that we have a service to store our data, and an activity to make it available, we can create a view to display it.

We’ll divide the view into three regions:

  1. A heading with the title of the app,
  2. a form to enter new task names,
  3. the current list of items,
  4. a footer that shows a status bar when the list is not empty.

Since we’ll want the entire page to scroll up and down, we start with a UIScrollContainer component. Within, we’ll want to restrict the width of the page and add some padding while keeping all content at the top of the page, which can be achieved using the UIFlowCell component.

Note: We’ll be using constructor factories in this example, instead of JSX. Refer to Using JSX for instructions on how to set up and use JSX.

Here’s a start for the page header within the two wrapper components:

UIScrollContainer.with(
  UIFlowCell.with(
    {
      dimensions: { width: 640, maxWidth: "100vw" },
      position: { gravity: "center" },
      padding: { top: 32, x: 16 }
    },

    // page header:
    UIRow.with(
      UILabel.withIcon("check", 40, "@green"),
      UIHeading1.withText("Todo"),
    )
  )
)

For the task input form, well use a UIFormContextController component. This introduces a ‘form context’ object to its child components, which can be used for two-way synchronization of input values (as opposed to bindings, which only perform one-way updates). We’ll bind the form context to the formInput record we’ve created above.

The form itself consists of a text field and a button.

  • The text field has a name property that’s set to the form context property name, which we’ll use to read and write the input value.
  • The text field and button share a common event handler addTask() that refers to the addTask method that’s part of the Activity component.

Add the following code to the UIFlowCell wrapper:

// ... add this to the UIFlowCell above:
UIFlowCell.with(
  {
    padding: { x: 16, y: 8 },
    borderColor: "@separator",
    borderThickness: 1
  },
  UIFormContextController.with(
    { formContext: bind("formInput") },
    UIRow.with(
      UIBorderlessTextField.with({
        name: "newTask",
        placeholder: "Enter a task...",
        onEnterKeyPress: "addTask()"
      }),
      UIBorderlessButton.withLabel(
        "Add", "addTask()")
    )
  )
)

For the list of current tasks, we’ll use the UIListController component and bind its items property to a managed list. In this case we can bind directly to the items list of the TodoService. The service is referenced by the Activity using its todo property, so we’ll pass bind("todo.items") to the list controller constructor.

The list controller encapsulates a component constructor (second argument to with below) that works as a ‘template’ for all items in the list. When the list emits its change events, the list controller updates the list automatically by adding, removing, and reordering instances of this constructor.

The template constructor should accept a single argument — the list item object itself — and render a UIContainer component. This makes the component an ‘adapter’ for the item. We don’t need to create an adapter from scratch, instead we can use the UIListCellAdapter class, which exposes an object property that we can bind to from within the cell component.

UIListController.with(
  {
    items: bind("todo.items"),
    onToggleTask: "toggleTask()"
  },

  // list item template:
  UIListCellAdapter.with(
    { padding: { x: 16, y: 8 } },
    UIRow.with(
      UIToggle.with({
        state: bind("object.complete"),
        onChange: "+ToggleTask"
      }),
      UIExpandedLabel.with({
        text: bind("object.text"),
        onClick: "+ToggleTask"
      })
    )
  ),

  // list container:
  UIFlowCell.with({
    separator: { type: "line" }
  })
)

Note how we’re emitting a custom ToggleTask event from within the UIListCellAdapter. The adapter propagates this event, while also adding an object property that refers to the list item (we use this as e.object in the Activity code above) — however we’ll need to handle the event outside the adapter itself because if we handle it before propagation, the event doesn’t contain this special property yet.

Finally, we’ll add a footer. We can hide the footer cell when it’s not needed by binding its hidden property to the count property of our managed list — except we need to show the list when count is nonzero, so the binding to use for hidden is bind("!todo.items.count").

The footer mostly has a label with the number of uncompleted tasks remaining, which we’ll create using UILabel.with, but we’ll also set the text color to {@text/50%} (see colors), and incorporate a nested binding using ${todo.nRemaining} (see bindf). We can even use I18n pluralization using #{/s}, all using a single string.

UIFlowCell.with(
  { hidden: bind("!todo.items.count") },
  UISeparator,
  UISpacer,
  UICenterRow.with(
    UILabel.with({
      text: bindf("${todo.nRemaining} task#{/s} remaining"),
      textStyle: { color: "@text/50%" }
    }),
    UILinkButton.with({
      hidden: bind("!todo.nCompleted"),
      label: "Remove completed",
      onClick: "removeCompleted()"
    })
  )
)

Here’s the final version of our src/activities/main/view.ts file, with some transitions and other features added in:

import { HMR } from "@typescene/webapp";
import { bind, tl, UIBorderlessButton, UIBorderlessTextField,
  UICenterRow, UIExpandedLabel, UIFlowCell,
  UIFormContextController, UIHeading1, UILabel,
  UILinkButton, UIListCellAdapter, UIListController,
  UIRow, UIScrollContainer, UISeparator, UISpacer, UIStyle,
  UIStyleController, UIToggle } from "typescene";

export default HMR.enableViewReload(
  module,
  UIScrollContainer.with(
    UIFlowCell.with(
      {
        dimensions: { width: 640, maxWidth: "100vw" },
        position: { gravity: "center" },
        padding: { top: 32, x: 16 },
        revealTransition: "fade"
      },

      // --------------------------------------
      // page heading:
      UIRow.with(
        UILabel.withIcon("check", 40, "@green"),
        UIHeading1.withText("Todo"),
      ),
      UISpacer.withHeight(32),

      // --------------------------------------
      // new task input form:
      UIFlowCell.with(
        {
          padding: { x: 16, y: 8 },
          borderColor: "@separator",
          borderThickness: 1
        },
        UIFormContextController.with(
          { formContext: bind("formInput") },
          UIRow.with(
            UIBorderlessTextField.with({
              name: "newTask",
              placeholder: "Enter a task...",
              requestFocus: true,
              onEnterKeyPress: "addTask()"
            }),
            UIBorderlessButton.withLabel(
              "Add", "addTask()")
          )
        )
      ),
      UISpacer.withHeight(16),

      // --------------------------------------
      // items list:
      UIListController.with(
        {
          items: bind("todo.items"),
          onToggleTask: "toggleTask()"
        },

        // list item template:
        UIListCellAdapter.with(
          { padding: { x: 16, y: 8 } },
          UIRow.with(
            { revealTransition: "down-fast" },
            UIToggle.with({
              state: bind("object.complete"),
              onChange: "+ToggleTask"
            }),
            UIStyleController.with(
              {
                state: bind("object.complete"),
                styles: {
                  true: UIStyle.create({
                    textStyle: { strikeThrough: true }
                  })
                }
              },
              UIExpandedLabel.with({
                text: bind("object.text"),
                onClick: "+ToggleTask"
              })
            )
          )
        ),

        // list container:
        UIFlowCell.with({
          separator: { type: "line" },
          animatedContentRenderingDuration: 200
        })
      ),

      // --------------------------------------
      // footer, if non-empty
      UIFlowCell.with(
        { hidden: bind("!todo.items.count") },
        UISeparator,
        UISpacer,
        UICenterRow.with(
          UILabel.with({
            text: bindf("${todo.nRemaining} task#{/s} remaining"),
            textStyle: { color: "@text/50%" }
          }),
          UILinkButton.with({
            hidden: bind("!todo.nCompleted"),
            label: "Remove completed",
            onClick: "removeCompleted()"
          })
        )
      )
    )
  )
)

That’s all — we’ve created a service, an activity, and a view. Run the npm run start command on the command line and point a browser to http://localhost:8080/ to see your app in action. Thanks to the HMR code above (for ‘Hot Module Reload’, a feature of both Webpack and Parcel) you can make changes to the view’s source code, save the file, and instantly see your changes in the browser. Note that this doesn’t work for the activity or service, since those would affect the state of the running application.

View or clone the above source code on GitHub at typescene/first-project, and see it online here.