2.0.0 • Published 3 years ago

oldskull v2.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
3 years ago

Old Skull Framework

New front-end framework for old software developers. 🧙‍♂️

Features:

  • Written in TypeScript (strict mode) using OOP
  • Implements Model-View-Presenter architecture
  • Relies on Observer design pattern for communication
  • Smaller and faster than mainstream frameworks
  • Fully covered by documentation
  • Provides direct access to DOM
  • Has no dependencies

Contents

Motivation

Modern front-end frameworks have a tendency to isolate developers from native platform and impose its own unique way of UI development that completely locks people in their isolated ecosystems.

For example they:

As a result we have to deal with fat and slow SPA developed by people who don't know how their application platform works and who aren't exposed to common computer science knowledge.

Old Skull Framework is developed in a different way, it:

  • Hides nothing from a developer

  • Uses well-known terms, design patterns and paradigms

  • Open for learning, changing and extension

Overview

As was mentioned before oldskull implements Model-View-Presenter architecture. It means UI logic is always divided into three loosely coupled parts:

  • Model stores and manages data
  • View displays that data and handles user input
  • Presenter acts as an intermediary between them

This architecture is implemented as a set of classes: OsfView, OsfModel, OsfModelView, OsfCollection, OsfCollectionView and OsfPresenter

OsfView creates and manages DOM structures:

import { OsfView } from 'oldskull';

class TaskView extends OsfView {
  getHTML() {
    return `
      <div class="task">
        <h2>Example task</h2>
        <button class="btn btn-complete">
          Complete
        </button>
      </div>
    `;
  }
  domEvents = [
    {
      el: '.btn-complete',
      on: 'click',
      call: this.markAsCompleted.bind(this),
    },
  ];
  markAsCompleted() {
    // ...
  }
}

const taskView = new TaskView();
await taskView.init();

OsfModel wraps data and implements business logic:

import { OsfModel } from 'oldskull';

interface ITask {
  id: number;
  name: string;
  isCompleted: boolean;
}

class TaskModel extends OsfModel<ITask> {
  switchStatus() {
    // ...
  }
}

const taskModel = new TaskModel({
  id: 1,
  name: 'Go for a walk',
  isCompleted: false,
});

OsfModelView is a OsfView that's able to display a Model and handles its events:

import { OsfModelView, MODEL_CHANGED_EVENT } from 'oldskull';

class TaskView extends OsfModelView<TaskModel> {
  getHTML() {
    const task = this.model.attrs;
    return `
      <div class="task">
        <h2>${ task.name }</h2>
      </div>
    `;
  }
  modelEvents = [
    {
      on: MODEL_CHANGED_EVENT,
      call: this.handleModelChange.bind(this)
    },
  ];
  handleModelChange() {
    // ...
  }
}

const taskView = TaskView(taskModel);
await taskView.init();

OsfCollection is just a set of Models that can be rendered by OsfCollectionView:

import { OsfCollection, OsfCollectionView } from 'oldskull';

class TaskCollection extends OsfCollection<TaskModel> {
  // Nothing's here for now
}

const tasks = new TaskCollection([
  new TaskModel({ id: 1, name: 'Do this', isCompleted: false }),
  new TaskModel({ id: 2, name: 'Do that', isCompleted: false }),
]);

class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
  constructor(collection: OsfCollection<TaskModel>) {
    super(collection, TaskView, NoTasksView);
  }
  getHTML() {
    return '<div class="tasks"></div>';
  }
}

const taskListView = new TaskListView(tasks);
await taskListView.init();

OsfPresenter creates and manages a Model/View pair:

import { OsfPresenter } from 'oldskull';

class TaskPresenter extends OsfPresenter<TaskModel, TaskView> {
  model = new TaskModel();
  view = new TaskView(this.model);
  viewEvents = [
    {
      on: 'completed',
      call: this.handleViewCompleted.bind(this),
    },
  ];
  modelEvents = [
    {
      on: 'change isCompleted',
      call: this.handleModelStatusChange.bind(this),
    },
  ];
  async beforeInit() {
    // Model initialization here
  }
  handleViewCompleted() {
    // Update a value in Model
  }
  handleModelStatusChange() {
    // Call a View method that updates displayed status
  }
}

const taskPresenter = new TaskPresenter();
await taskPresenter.init();

Below you will find detailed documentation on all mentioned classes and a few others that allow you to:

  • Define application entry point: OsfApplication
  • Nest and switch Views: OsfRegion
  • Trigger and listen custom events: OsfObservable

See also:

Installation

yarn: yarn add oldskull

npm: npm install oldskull --save

It's highly recommended to import all the oldskull entities inside of your project and re-export them for internal use:

// ./utils/framework/index.ts

import {
  OsfRenderable,
  IOsfRenderable,
  OsfObservable,
  IOsfObservable,
  OsfView,
  IOsfView,
  OsfModel,
  IOsfModel,
  OsfModelView,
  IOsfModelView,
  OsfCollection,
  IOsfCollection,
  OsfCollectionView,
  IOsfCollectionView,
  OsfPresenter,
  IOsfPresenter,
  OsfApplication,
  OsfRegion,
  OsfReference,
  OsfInsertPosition,
  ALL_EVENTS,
  MODEL_CHANGED_EVENT,
  MODEL_ADDED_EVENT,
  MODEL_REMOVED_EVENT,
  COLLECTION_RESETED_EVENT,
} from 'oldskull';

export {
  OsfRenderable as Renderable,
  IOsfRenderable as IRenderable,
  OsfObservable as Observable,
  IOsfObservable as IObservable,
  OsfView as View,
  IOsfView as IView,
  OsfModel as Model,
  IOsfModel as IModel,
  OsfModelView as ModelView,
  IOsfModelView as IModelView,
  OsfCollection as Collection,
  IOsfCollection as ICollection,
  OsfCollectionView as CollectionView,
  IOsfCollectionView as ICollectionView,
  OsfPresenter as Presenter,
  IOsfPresenter as IPresenter,
  OsfApplication as Application,
  OsfRegion as Region,
  OsfReference as Reference,
  OsfInsertPosition as InsertPosition,
  ALL_EVENTS,
  MODEL_CHANGED_EVENT,
  MODEL_ADDED_EVENT,
  MODEL_REMOVED_EVENT,
  COLLECTION_RESETED_EVENT,
};

This way you can always easily extend or override functionality of core classes afterwards without messing with each and every child class definition.

Classes

Observable

OsfObservable class implements Observer design pattern.

Objects of this class can trigger events with optional payload, other objects can listen and handle those events.

import { OsfObservable } from 'oldskull';

const NEW_LETTER = 'NEW_LETTER';

class Mailbox extends OsfObservable {
  add(letter: string) {
    this.trigger(NEW_LETTER, letter);
  }
}

const mailbox = new Mailbox();

mailbox.on(NEW_LETTER, (data: unknown) => {
  const letter = <string>data;
  console.log('New letter: ', letter);
});

Almost all oldskull classes already extend OsfObservable and able to trigger events.

An event can be triggered with or without a payload of any type:

this.trigger('eventName');

this.trigger('eventName', {
  // ...
});

this.trigger('eventName', 'eventDescription');

To add an event handler use on method:

function eventHandler(payload: unknown) {
  // ...
}

somethingObservable.on('eventName', eventHandler);

To remove an event handler use off method:

somethingObservable.off('eventName', eventHandler);

There is also a special method that simplifies the case when observable object needs to retrigger an event from other observable:

// in a method of custom observable class
somethingObservable.on(ALL_EVENTS, this.retrigger.bind(this));

Retriggering is often happens in OsfCollectionView (described below) to pass events from child Views to proper handler in a parent:

class ExampleCollectionView extends OsfCollectionView {
  // ...
  viewEvents = [
    { on: ALL_EVENTS, call: this.retrigger.bind(this) },
  ];
}

Renderable

OsfRenderable is a base class for all renderable entities like OsfView, OsfModelView, OsfCollectionView, OsfPresenter and OsfApplication.

Classes that extend it have:

  • el property containing created HTML Element

  • async init() method that creates and initializes el.

  • Hook methods beforeInit() and afterInit() that are called once on first init() call.

  • remove() method that removes el from DOM end performs finalization.

  • Hook methods beforeRemove() and afterRemove() that are called on each remove() call.

By default all hooks methods does nothing and meant to be overwritten by child classes:

class MyView extends View {
  // ...
  async beforeInit() {
    // right before this.el creation and initialization
  }
  async afterInit() {
    // right after this.el creation and initialization
  }
  addOpacity() {
    this.el?.style.opacity = 0.5;
  }
  async beforeRemove() {
    // right before this.el removed
  }
  async afterRemove() {
    // right after this.el removed
  }
}

// Create Element
const myView = new MyView();
await myView.init();

// Do something with it
document.body.append(myView.el);

// And remove it when it's no longer needed
myView.remove();

View

OsfView class creates and manages DOM structures.

To define a desired DOM structure implement getHTML() method returning HTML string:

import { OsfView } from 'oldskull';

class GreetingView extends OsfView {
  getHTML() {
    return `
      <div class="greeting">
        <h1>Hello</h1>
        <p>How are you?</p>
      </div>
    `;
  }
}

Any view has init()/remove() methods, el property with created Element and lifecycle hooks. See OsfRenderable class for details.

By default View presumes it renders a general Element but if you render something different you can always specify a subtype using generics:

class ExampleInputView extends OsfView<HTMLInputElement> {
  getHTML(): string {
    return `<input type="text" value="hello">`;
  }
}

const view = new ExampleInputView();
await view.init();
view.el?.value === 'hello' // true, no type error

Views can listen to DOM events and handle them:

class HeaderView extends OsfView {
  getHTML() {
    return `
      <div class="header">
        <button class="btn-logout">
          Log out
        </button>
      </div>
    `;
  }
  domEvents = [
    {
      // omit "el" to add event handler to the root element
      el: '.btn-logout',
      on: 'click',
      call: this.handleLogout.bind(this),
    },
  ];
  handleLogout(event: Event) {
    // ...
  }
}

Views should be responsible only for UI displaying and user input handling. Data management and any other kind of business logic should happen outside.

Model

OsfModel class encapsulates your data and provides common API for its management.

Preferable way of defining Models is class inheritance:

import { OsfModel } from 'oldskull';

interface ITask {
  id: number;
  name: string;
}

class TaskModel extends OsfModel<ITask> {
  // Implement task specific methods here
  // or leave it empty for now
}

const taskModel = new TaskModel({
  id: 1,
  name: 'Example task',
});

You can avoid class creation and rely on generics but this way you will lose an important ability to add common Model functionality without mass code refactoring:

const task = new OsfModel<ITask>({
  id: 1,
  name: 'Example task',
});

If you want to be able to create Model instances with default values you can overwrite Model constructor:

class TaskModel extends OsfModel<ITask> {
  constructor(attrs?: ITask) {
    super();
    this.attrs = attrs || {
      id: 0,
      name: '',
    };
  }
}

const taskModel = new TaskModel();

Model attributes (this.attrs) is our data managed by the Model. You can read directly from it but must avoid writing to it:

const taskModel = new TaskModel();
console.log(`Task id is `, taskModel.attrs.id);

To modify attributes you should use set() or setAttribute() methods that automatically trigger Model change events with model entity as a payload:

const taskModel = new TaskModel();

// Handle all model changes
taskModel.on('change', (data: unknown) => {
  const taskModel = <TaskModel>data;
  console.log('The task was changed:', taskModel);
});

// Handle only "name" attribute changes
taskModel.on('change name', (data: unknown) => {
  const taskModel = <TaskModel>data;
  console.log('The task name was changed:', taskModel.attrs.name);
});

// Update all attribute values
taskModel.set({ id: 1, name: 'First task' });

// Update single attribute value
taskModel.setAttribute('name', 'foobar');

When you're manually adding event handlers (not via modelEvents/viewEvents) don't forget to remove them later, not doing so can cause memory leaks.

Instead of writing 'change' string each time you can use MODEL_CHANGED_EVENT variable with the same value:

import { MODEL_CHANGED_EVENT } from 'oldskull';

taskModel.on(MODEL_CHANGED_EVENT, (data: unknown) => {
  // ...
});

If you plan to use a Model inside a Collection you should implement getId() method that returns unique id of the entity:

class TaskModel extends OsfModel<ITask> {
  getId(): number {
    return this.attrs.id;
  }
}

ModelView

OsfModelView is an extended OsfView that's able to display Model attributes:

import { OsfModelView } from 'oldskull';

class TaskView extends OsfModelView<TaskModel> {
  getHTML() {
    const task = this.model.attrs;
    return `
      <div class="task">
        <h2>${ task.name }</h2>
      </div>
    `;
  }
}

Make sure you perform XSS sanitization of untrusted fields using DOMPurify or similar tools.

ModelView also can listen and handle Model events:

import { OsfModelView, MODEL_CHANGED_EVENT } from 'oldskull';

class TaskView extends OsfModelView<TaskModel> {
  getHTML() {
    // ...
  }
  modelEvents = [
    {
      on: MODEL_CHANGED_EVENT,
      call: this.handleModelChange.bind(this),
    },
  ];
  handleModelChange() {
    // ...
  }
}

This way of model event handling is fine but use of OsfPresenter is preferable.

Collection

OsfCollection class encapsulates a set of Models and provides an API for its management.

import { OsfCollection } from 'oldskull';

class TaskCollection extends OsfCollection<TaskModel> {
  // Nothing's here for now
}

const taskCollection = new TaskCollection([
  new TaskModel({ id: 1, name: 'Do this'}),
  new TaskModel({ id: 2, name: 'Do that'}),
]);

Collections as child classes may look unnecessary at first but it becomes very useful when you start to implement entity-specific logic like loading items from server.

You can add and remove Models from a Collection and handle those events:

const taskCollection = new OsfCollection<TaskModel>();

taskCollection.on(MODEL_ADDED_EVENT, (payload: unknown) => {
  const task = <TaskModel>payload;
  console.log('Task was added:', task);
});

taskCollection.on(MODEL_REMOVED_EVENT, (payload: unknown) => {
  const task = <TaskModel>payload;
  console.log('Task was removed:', task);
});

taskCollection.on(COLLECTION_RESETED_EVENT, () => {
  console.log('All tasks was replaced');
});

// Add a model
taskCollection.add(taskModel0);

// Add a set of tasks
taskCollection.add([ taskModel1, taskModel2, taskModel3 ]);

// Remove a model by id
taskCollection.remove(1);

// Set array of models
// Already stored taskModel3 will be merged with provided
// Other models will be removed
taskCollection.set([taskModel3]);

// Reset array of models
// Skips merging and triggering model removal events
// Just overwrites Model array and triggers reset event
taskCollection.set([taskModel2]);

You can get specific Model from Collection by providing a Model id:

const taskModel = taskCollection.get(2);

It's possible to access Models directly by using models property but try to avoid it when possible:

const taskModel =
    taskCollection.models.find(model => model.attrs.id === 2);

CollectionView

OsfCollectionView class is a View that creates a container Element and inside of it renders Models from a Collection using provided OsfModelView.

Default container element is <div></div> but you can change it by overwriting getHTML() method:

import { OsfCollectionView } from 'oldskull';

class TaskListView extends OsfCollectionView<TaskModel, TaskView> {
  constructor(collection: OsfCollection<TaskModel>) {
    super(collection, TaskView);
  }
  getHTML(): string {
    return '<div class="tasks"></div>';
  }
}

const taskListView = new TaskListView(tasks);
await taskListView.init();

If a Collection has no Models then other View called "EmptyView" can be rendered instead:

import { OsfCollectionView } from 'oldskull';

class NoTasksView extends OsfView {
  getHTML(): string {
    return '<p>No tasks</p>';
  }
}

class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
  constructor(collection: OsfCollection<TaskModel>) {
    super(collection, TaskView, NoTasksView);
  }
}

ModelViews can be rendered inside of specified child element instead of root element by defining childViewContainer property with an OsfReference:

class TaskListView extends OsfCollectionView<TaskModel, TaskView> {
  // ...
  getHTML(): string {
    return `
      <div class="tasks">
        <h2>
          Tasks
        </h2>
        <div class="task-list">

        </div>
      </div>
    `;
  }
  childViewContainer = new OsfReference(this, '.task-list');
}

CollectionView can listen to events from a Collection and child Views:

class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
  collectionEvents = [
    {
      on: MODEL_ADDED_EVENT,
      call: this.addChildView.bind(this),
    },
  ];
  viewEvents = [
    {
      on: 'completed',
      call: this.handleViewCompleted.bind(this),
    },
  ];
  handleViewCompleted() {
    // ...
  }
}

Set filterFunc and sortFunc properties on a CollectionView to filter and sort models before initial rendering:

class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
  filterFunc = (models) => models.filter((m) => m.attrs.name !== '');
  sortFunc = (models) => models.reverse();
}

There are three methods for managing Views inside a CollectionView:

  • addChildView(modelOrArrayOfModels) adds a ModelView that renders provided Model(s)

  • removeChildView(modelId) removes a ModelView that renders a model with specified id

  • removeAllChildViews() removes all rendered ModelViews

You can specify a position where you want to append new ModelView using optional parameters of addChildView method:

import { OsfInsertPosition } from 'oldskull';

// Insert at the beginning of a list
await view.addChildView(modelTwo, OsfInsertPosition.Beginning);

// Insert at the begging
await view.addChildView(modelOne, OsfInsertPosition.Before, modelTwo);

// Insert at the begging
await view.addChildView(modelThree, OsfInsertPosition.After, modelTwo);

// Insert at the end of a list
await view.addChildView(modelFour, OsfInsertPosition.End);

Simple CollectionView can be instantiated without definition of child class but this approach in general should be avoided because it makes your code less maintainable:

const taskListView = new OsfCollectionView(collection, TaskView, NoTasksView);

Presenter

OsfPresenter class is responsible for creation and initialization of a Model/View pair and handling of their events:

import { OsfPresenter } from 'oldskull';

class TaskPresenter extends OsfPresenter<TaskModel, TaskView> {
  model = new TaskModel();
  view = new TaskView(this.model);
  viewEvents = [
    {
      on: 'completed',
      call: this.handleViewCompleted.bind(this),
    },
  ];
  modelEvents = [
    {
      on: 'change isCompleted',
      call: this.handleModelStatusChange.bind(this),
    },
  ];
  async beforeInit() {
    // Model initialization here
  }
  handleViewCompleted() {
    // Update a value in Model
  }
  handleModelStatusChange() {
    // Call a View method that updates displayed status
  }
}

const taskPresenter = new TaskPresenter();
await taskPresenter.init();

Presenter class extends OsfRenderable so you can make use of lifecycle methods like beforeInit/afterInit to initialize Model attributes and perform other necessary actions that are out of Model/View scope.

Reference

OsfReference is used in Views to get access to nested DOM Elements that was rendered by them.

import { OsfReference } from 'oldskull';

class LayoutView extends OsfView {
  getHTML() {
    return `
      <div class="root">
        <div class="header"></div>
        <div class="content"></div>
        <div class="footer"></div>
      </div>
    `;
  }
  content = new OsfReference<HTMLElement>(this, '.content');
  afterInit() {
    const contentEl = this.content.get();
    contentEl.classList.add('loaded');
  }
}

To create a Reference just pass a View and a CSS selector of the needed element to the constructor and specify referenced element type in generic.

Actual Element can be obtained by get() call.

Region

OsfRegion allows to render Views and Presenters inside of specified DOM Element that was rendered by the View:

import { OsfRegion } from 'oldskull';

class LayoutView extends OsfView {
  getHTML() {
    return `
      <div class="page">
        <div class="header"></div>
        <div class="content"></div>
        <div class="footer"></div>
      </div>
    `;
  }
  headerRegion = new OsfRegion(this, '.header');
  contentRegion = new OsfRegion(this, '.content');
  footerRegion = new OsfRegion(this, '.footer');
  async afterInit() {
    await this.headerRegion.show(new HeaderView());
    await this.contentRegion.show(new ArticlesPresenter());
    await this.footerRegion.show(new FooterView());
  }
}

Region's constructor accepts a View and CSS selector of an Element where is should be attached.

Region itself provides only two methods:

  • show(viewOrPresenter) to display a renderable item
  • empty() to remove what's currently rendered in the region

Application

OsfApplication is a skaffold for an application entry point.

import { OsfApplication } from 'oldskull';

export class MyApp extends OsfApplication {
  async init() {
    await this.mainRegion.show(new TaskListPresenter());
  }
}

const app = new MyApp('#root');
app.init();

It creates a mainRegion on the Element found by provided CSS selector and expects you to implement init() method that performs application start.

For more thorough example see index.ts from oldskull-realworld.

Usually initialization logic sets up:

  • Router
  • Global error handler
  • Custom logger
  • Page layout

FAQ

Can it be used in production?

Not yet.

Right now it's more like public beta so breaking changes still may appear based on initial feedback.

Does it support server-side rendering?

Not yet.

Draft SSR implementation showed that there is no way to implement it in a more or less appropriate manner without dirty hacks, performance penalties and code quality deterioration.

If you know how to make it possible without mentioned drawbacks feel free to tell us.

Are there UI kits for it?

Not yet.

For now you can use CSS frameworks that apply styles to common elements via global styles or class usage.

If you plan to implement your own UI kit we recommend to try to make it in a framework-agnostic way so it could be used without any framework or with any other framework that doesn't hide DOM access.

Isn't it just a Marionette.js clone?

Well, it is. But at least it doesn't depend on Backbone/Underscore/JQuery and written in TypeScript that's much more suitable for SPA development.

2.0.0

3 years ago

1.0.0

3 years ago

0.1.2

3 years ago

0.1.1

3 years ago

0.1.0

3 years ago