celeritas-ui-core

0.4.0 • Public • Published

Celeritas UI - Core

Disclaimer: This UI application framework is a work-in-progress and is not yet intended for widespread use. Please contact the package author if you are interested in potentially using this package.

This package holds the primary core functionality for the Celeritas-UI SPA framework. In order to run the Celeritas-UI framework, you must use this Celeritas-UI-Core package. This package handles building and bundling a Celeritas-UI app, as well as library functionality for handling URI routing, views, components, and data caching.

This package can be used in conjuction with the Celeritas-UI-Ext package, which extends the base functionality to support various common UI app workflows, such as charts, CSV tools, file uploading/downloading and so forth.

Celeritas-UI Framework

What is the Celeritas-UI framework? It is primarily designed to build UI apps from a common template. It is designed to be straightforward to use and work with, and to have a minimal code blueprint. The idea behind it was to make a UI framework using vanilla JavaScript (with just a little jQuery) that can compete with the more robust frameworks and libraries like React, Angular or Vue.js; without all of the bloat.

How does it work

Celeritas-UI works similarly to a classical Model-View-Controller setup, but not strictly.

  • Views: Views in Celeritas-UI define the make-up of a page. Generally, this may be some static make-up, and components which will be loaded when the view loads.
  • Components: Components in Celeritas-UI are more modular data-driven building blocks for the app.
  • Services: Services are optional; but act as the Controller in this paradigm. They handle any operations for retrieving or updating data.

Celeritas-UI uses the Mustache templating language for rendering views and components. It does not do a straight HTML swap to render content; instead it iteratively compares the diffs in HTML content between the existing nodelist and the newly rendered nodelist, and will hotswap in the new or changed content, leaving prior content as-is. This ensures quick rendering performance, and prevents jittery UI changes, since un-changed content remains untouched by the rendering process.

In this way, it is possible to change or update the underlying data which is powering a component without re-rendering the entire compontent.

Celeritas-UI is also recommended to be used with Bootstrap and SCSS.

Celeritas-UI App Anatomy

Celeritas-UI apps follow a strict project directory anatomy. Here is an example of a simple project directory using only the required folders and files (with samples of mycomponent and myview for demonstrative purposes): -

myapp/
	components/
		mycomponent/
			component.mst
	dist/
	env/
		dev.json
	views/
		myview/
			view.mst
	app.js
	index.html

A "Full" Celeritas-UI app may look more like this: -

myapp/
	components/
		mycomponent/
			component.mst
			component.js
			component.scss
	dist/
	env/
		dev.json
	handlers/
		myhandler.js
	js/
		mylib.js
	locale/
		en-US.json
	scss/
		global.scss
	services/
		myservice.js
	views/
		myview/
			view.mst
			view.js
			view.scss
	app.js
	index.html

Views and Components are defined by creating directories (or sub-directories) in the ./views and ./components directories of your app project. Each view and component requires a .js to define the behavior, and a .mst template file which is used to render that view or component. Additionally, if an scss file is included in this directory it will be bundled up into the final styles.css file as well. This helps to keep scss files or a given view or component adjacent to that content in the same directory instead of amongst everything else in a single scss or styles directory which holds all SCSS/CSS content.

  • ./components: Contains all components used in the app. Each component is a folder, which requires a singular .mst file which defines how to render that component. May also contain .js files, and .scss files which modify and style that component.
  • ./dist: When the app is bundled, there will be a bundle.js and styles.css file which contain all the JavaScript and CSS for the complete app. These can be included on the index.html file to be usable.
  • ./env: Celeritas-UI apps are expected to be built to a target environment, such as dev or prod. The environment JSON will be loaded into the app; so if you have dynamic variables for your target environment they can be stored here. If you have no need for multiple deployment environments, you can specify just prod to always use.
  • ./handlers: Handlers are optional. In the traditional Celeritas-UI app design paradigm, a handler is used to define the behavior of a form submission. Classes defined here will automatically become instantiated by the app and become available under app.handlers[handlername].
  • ./js: Optional; if you have any additional JS, such as classes, helpers, or anything else that your app may use, you can define them under this folder in any way you wish, and they will be bundled into the project.
  • ./locale: Optional, the Celeritas-UI framework supports multi-languae localization. You can create a JSON file for a given locale, such as english or en-US and define the terms and language content. These can then be referenced in all templates, allowing for easy localization.
  • ./scss: Any/all SCSS files in this directory will be compiled into the end ./dist/styles.css bundle. However, it is also possible place .scss files inside view or component directories; so this top-level scss directory is intended for anything which is "global" or affects the entire app, instead of specific views or components.
  • ./services: Optional; this behavior functions the same as the ./js folder, though under the Celeritas-UI app design paradigm, services are intended to be used to bridge data and components. Services are intended to be back-end interfaces; which do not interact with the page or visual content, and only read/write data from/to an API. Classes defined here will automatically become instantiated by the app and become available under app.services[servicename].
  • ./views: Contains all views available in an app. Views power the app URI routing, so in order to have different URI views in your app, you would need a view for each.
  • ./app.js: This should contain an App class which extends the CeleritasUI app class. You can customize your overall app behavior and properties/configuration in this class.
  • ./index.html: The "single page" of the single-page-app for Celertias-UI. It should refernece all JS and CSS needed for your app, and should initialize the app and contain a master <div> element for rendering the app.

If you want to create a Celeritas-UI app, we recommend reviewing the Celeritas-UI-Sample GIT project; this is a full-belown sample application which implements all of the functionality of the Celeritas-UI framework. If you want to make a Celeritas-UI app project from scratch, we recommend forking the Celeritas-UI-App GIT project; as this will give you a baremetal app project folder to get started with.

window.app

Generally, it is expected that you'll initiate the app in your index.html file in the following way: -

window.app = new CeleritasUI({ ... });

Or, if you have specified a class like MyApp in ./app.js which extends CeleritasUI then you might use window.app = new MyApp({ ... });.

Any properies passed in via the object will be set as properties of your app object.

The app object allows you to access and manipulate any/all functionality of the app. For example, once the app has loaded, you could reference app.current_view to manipulate the current view, or its components from there. If you create various services, or handlers, they will be available as well. For example, if you created a MyService class under ./services, then that class will be auto-instantiated and available under app.services.myservice. Same for handlers.

Views

A view is intended only to describe the "skeleton" of the view. It can also describe the behavior of a view, such as the URI route which triggers that view, and if it should be a default view or something that is blocked by authentication.

A lot of behavior for a Celeritas-UI app is abstracted by the project setup. For example, if I create a ./views/dashboard directory, it will automatically make a route localhost/#/dashboard view available using the default View class. Although, a mst file is still needed to define what to render.

Files in a views/ or components/ directory do not strictly need to be named after the directory. When the app is bundled, it instead auto-locates the views in directory. For example, if you wanted to make a dashboard view, you could specify the view under ./views/dashboard/dashboard.js or you could instead call it ./views/dashboard/index.js, or anything else. You can have as many js or scss files in a view directory that you want. However, there should only be one mst file which defines the way that view renders.

Let's create a very simple "root" view. If I didn't care at all how this view behaved, I could omit the .js file entirely. However, in this case since I've named the view directory "root", I want to define the route as being / instead of /root.

./views/root/root.js

class RootView extends View {

	constructor (app, options) {
		super(app, Object.assign(options, {
			default: true,
			requires_auth: false,
			route: "/"
		}));
	}

}

Options: -

  • default: This defines if this view should be treated as the "default" view. Since we made this our "default" view, the app will force/redirect to this view if you try to access anything which does not exist.
  • requires_auth: The Celeritas-UI framework has some basic functionality for handling "authenticated" (logged in) vs. non-authenticated states. This property allows you to specify if a given view should be restricted to "logged in" users. You could further define/customize this behavior with some overloadable methods on the view or component classes which we'll talk about later.
  • route: This is optional. If omitted, the URI route is defined by the name of the view directory. If omitted in this case, since our view in this example is ./views/root/, this would result in the route http://localhost/#/root.

Note: The URI routing for a Celeritas-UI app uses the # hash as the beginning of all URI routes.

There are other View class behaviors which we'll talk about below.

For this view, I would next create the template file which might look like this: -

./views/root/root.mst

<div>Hello World!</div>

Note: Because the rendering compares HTML node lists and iteratively adds/removes/updates these nodes instead of a straight HTML replace, any loose text that is not inside an HTML element in the template file likely will not be rendered.

If I wanted to use dynamic content/variables in my view, this is also possible. However, we recommend that views should only ever reference data/variables which are immediately available. If the data must come from a promise which may take time to execute, a component should be used instead.

How to use dynamic content in a view or component can be found further below, in the View & Component 'state' section.

Once a view has rendered, it can be accessible as app.current_view.

Components

Components make up the "meat" of the application, they are intended to be used as either modular or more granular parts to an application which are data-driven. For example, let's say we are building a "tasklist" type of application.

For a "tasklist" app, we probably need them to login first. So there may be a Login view, which doesn't have any dynamic content as it is only a form. This can be done with only a view. However, once you are logged in, we may need to fetch "tasks" data from an API to show the logged in user's task list.

In this case, we would have a Tasklist view which perhaps has the "header" and "footer" content of the app, the static stuff which doesn't change. But, the lists of tasks themselves should be a component. The component will decide how to fetch the data which makes up that content, and can be re-rendered when the task-list changes without affecting other content in the view (such as a separate component which maybe shows the details of the logged-in user somewhere in the header or menu).

To use a component in a view, you simply specify an empty HTML element with the data-component property to describe which component should load in that space, e.g.: <div data-component="tasklist"]></div>

When a view is loaded, it will detect all elements with the data-component attribute and proceed to render that component to that HTML node.

Components are coded similarly to views, but do not have URI routing. Here is an example component: -

./components/tasklist.js

/*
	Component classes should always be named as {parent_directory_name}Component, otherwise the app code may not be able to identify the extended component class.

	It is not strictly necessary to make a .js file for each component...
	If the component doesn't extend any functionality of the base Component class...
	Then you can just not create a component class and it will use the base Component class instead.
*/
class TasklistComponent extends Component {

	constructor (app, options) {
		super(app, options);

		this._set_state({
			//define component-level dependencies
			tasks: () => {return this.app.services.tasks.get_tasks()}
		});
	}

	/*
		This fuction will execute after the state dependencies have loaded but before the component has been rendered.
		You can use pre_render() to mutate data from the state before it is rendered.
	*/
	async pre_render () {
		
	}

	/*
		This function will execute after the template has rendered, but before post-render extensions have been loaded.
		The component will not yet be in a state of "fully rendered" whe this executes, it allows for manipulating the rendered component
		before it will be considered finishing rendering.
	*/
	async on_render () {
		
	}

	/*
		This function will execute after the component is considered fully rendered.
	*/
	async post_render () {

	}

}

There's a lot to unpack here.

  • this._set_state: This handles how the component's state is set. The state in this case refers to dynamic data that this component uses, which if changed, could cause the component to render again. More about state can be found in the below section View & Component 'state.
  • pre_render(), on_render(), post_render(): This are effectively "hooks" which occur at different stages of the component's render lifecycle. They execute before any data has been fetch, after the component has "rendered" but before it has been swapped into the view, and after everything has rendered and been shown to the user.

In this case, the primary reason we've used a component here is because the state is using a method which executes this.app.services.tasks.get_tasks(). This method interacts with an API service which may take some time to fetch the task's the user, since it is dynamic data coming from a back-end.

Here is an example of the template for this component: -

./components/tasklist.mst

<div class="tasks">
	{{#tasks}}
		<div class="task">{{name}}</div>
	{{/tasks}}
</div>

Since it is a Mustache template, we can reference the data stored in the state of this component. When this component is supposed to load as part of a view, it will execute the methods defined as part of the state to fetch the data it needs, and then the template will render using that data.

Once a component has rendered under a view, it can be accessible as app.current_view.components[component_name].

Lastly, it is possible to define any custom methods you want on a View or Component class. If you have functionality you want to employ for a component as part of that component, this is possible.

View & Component 'state'

Views and Components can both use state; in fact, views actually extend components and all of their functionality.

As described above in the component section, state refers to the dynamic data that a view or component may use when rendering their templates. The View or Component classes which are created in your project should define how the global or local state of that or component are resolved.

On both views and components it is possible to define state in two ways.

  • this._set_global_state: This will define a set of properties in the global state. These properties can be accessed at any time, by the view or by ALL of its components. This is data by reference; so if component A of a view changes the global state data, then this will also affect (and cause a re-render of) component B.
  • this._set_state: This can be used to define properties in the local state. These are only available to the view or component in which they are defined.

Both of these methods accept an object of methods. The methods should define how to "resolve" that state dependency. When a view or component renders it will attempt to "resolve" its dependencies via executing these methods and assigning their results to a state property of that view or component.

Within a component or view template, the data "context" is always the state. This is accessible as app.current_view.state or app.current_view.components[component_name].state.

Here is an example of a view and component. The view will use a global state, and a component will use local state.

./views/task.js: -

// the "Task" view.js
class TaskView extends View {

	constructor (app, options) {
		super(app, Object.assign(options, {
			default: false,
			requires_auth: true,
			route: '/tasks/{task_id}'
		}));

		//Note: the above route has a dynamic value in it, shown as {task_id}. This allows you to plugin variable values. The value passed in as {task_id} (let's say that is '42') will be passed down under `properties` to the view and its children components.

		this._set_global_state({
			//define global dependencies
			user: () => {return this.app.services.users.get_current_user()}
		});
	}

}

./views/task.mst: -

<div data-component="task">
</div>

As noted above, components are used in a view by using a "placeholder" div element which has a data-component attribute named for the component we want to put there.

./components/task.js: -

class TaskComponent extends Component {

	constructor (app, options) {
		super(app, options);

		this._set_state({
			//define component-level dependencies
			task: () => {return this.app.services.tasks.get_task(this.parent.state.properties.task_id)}
		});
	}
}

./components/task.mst: -

<div class="user">
	{{user.first_name}} {{user.last_name}}
</div>
<div class="task">
	{{task.name}}
</div>

Here is a breakdown of what will happen when this view and its component render to the screen.

  1. Determine which view to render based on the route (in this case, let's say this picked this 'Task' view)
  2. Resolve the global and local state dependencies of that view (in this case, execute user() as defined in the global state, and assign user as app.current_view.state.user).
  3. "Render" the view to the screen, the view will be shown but all components of the view will be in a "loading" state.
  4. For that rendered view, check each component which will be loaded. Automatically pass down the global state properties to that component. properties will be passed in as well, such as the variable task_id which we defined in the URL route.
  5. For each component, resolve the dependencies of that component and assign them to that component's state. In this case, we're choosing which task to load based on the properties based down from the parent view, which came from the URI route of the view we're looking at.
  6. "Render" each component to the screen.

In particular to note are steps #3 and #6. A good reason which you may not want to include state methods which take time to execute in the view state, is that we want to show the view as soon as possible. Otherwise, the entire app and screen will show either nothing, or a loading animation. We want to show as much as possible as soon as possible. From there, we load components which may take time to fetch data or render. This way, we can show a partial UI where, for example, maybe the left-hand menu is shown immediately but the primary "meat" of the view is shown as loading for a moment.

The above example is actually bad in this case, as the view is first fetching data about the user, and then passing that down to the task component. If the user data didn't need to be available at the view, it would be better to put it in the component's state instead. However, if we have other components which want to use that same user data, it can be beneifical to put it in the global state of the view.

state Behavior

The idea with state has multiple purposes. As demonstrated above, one key reason to use it to decide how dynamic data or variables are used to render your views or components.

But what happens when the data in that state for a view or component changes? Or when we want it to change? How does this affect the already rendered view or component? Especially if that is global state data which is being shown in multiple components on the same page?

There are a number of ways to handle this in Celeritas-UI, depending on your needs.

What happens if I change data within the state?

Nothing, at first. For example, if you were to manipulate the data of a component's state directly:

app.current_view.components.task.state.stask.name = "FooBar";

This will not immediately affect anything shown on the screen. In this way, it is possible to "stage" changes to the state without immediately triggering a render.

If you want to "commit" that change to the state, there are a few different options.

  1. Re-render the specific view or component. Views and Components each have a render() method. This will render the view/component with its current state. If the state has changed since the view/component was last rendered, this will re-render the view/component with the new version of the state. It does not freshly resolve the state however.

Example: -

app.current_view.components.task.state.name = "Something Different";
app.current_view.components.task.render();
  1. _update() the state property. The _update() method is available on each property of the state. For example: -
class TaskComponent extends Component {

	constructor (app, options) {
		super(app, options);

		this._set_state({
			//define component-level dependencies
			task: () => {return this.app.services.tasks.get_task(this.parent.state.properties.task_id)}
		});
	}
}

I can then change the state of that component, and trigger the update. This will cause all views or components which use that same state property to re-render.

app.current_view.components.task.state.name = "Something Different";
app.current_view.components.task._update();

It is also possible to pass the specific changes via the _update() directly.

app.current_view.components.task.state.task._update({name:"Something Different"});

The primary difference between rendering the component again, and updating the state as shown here, is that if the state in question is global, then updating that state will cause a re-render of all views and components which use that state, whereas triggering a render() of the view or component will only re-render that specific view or component.

Another thing to note is that Views and Components each have a refresh() method which may be executed as well. Unlike the approaches described above, refresh() will overwrite the state from scratch. Instead of re-rendering the component with the latest version of its state, refresh() will instead act as though the component is being rendered for the first time. It will not use the current version of state, it will use the methods defined under the view or component constructor to remake the state. This can be useful if you do not want to change the state of data within the app, but instead want the latest version of that data from the back-end or API service that you originally got that data from.

Services

Services do not extend any kind of base class, and are fully optional to use in your project; however they are included here with some baked-in functionality to help guide the building of an app with good separations of concerns.

Services are loaded in from any file defined under ./services, and become available under app.services[service_name]. When services are auto-constructed to be part of app.services, it takes a lowercase version of the service class name, and removes "Service". Hence, in the example below, "AuthService" becomes "auth".

The primary idea with services is to provide a layer which handles how data being shown in the UI application is fetched from the server, API, back-end, database, etc. and how that data is updated.

Services should not interact with the DOM of the webpage, or change any UI element. They should not trigger component renders, or anything which visually changes the screen.

Instead, services simply describe an API for components or handlers to use to manipulate data.

Here is an example service: -

/*
	Services are intended to handle business-logic level application code.

	Services aren't meant to interact with the DOM or any UI content.

	Instead, they should provide an abstract, re-usable API for dealing with the logical parts of the codebase.

	They should organize segments of the project by business-logic domain, e.g. a service for authentication, a service for users, etc.

	Methods should accept data that is already parsed from a form (via a Handler) and return clean data for that operation.
*/
class AuthService {
	constructor (app) {
		this.app = app;
	}

	async login (email, password) {
		try {
			//do some API interaction here to perform the login using payload.email, and payload.password from the login form.
			let auth_token = await this.app.api.login(email, password);

			//an error would be thrown if login failed. Therefore, it succced so update the app status to authenticated.
			this.app.authenticated = true;
			/*
				since this changes app.authenticated; if you defined an "on_authenticated()" method on the app singleton, then
				that method would now execute.
			*/

			/*
				Normally, you want to persist a user's session. Since the login API returned an auth_token, we'll save that in local storage.
			*/
			localStorage.setItem('auth_token', auth_token);
		}
		catch (err) {
			//if the service was called under the context of a form, this error would be caught and shown as a form submit error.
			//if the service was called under no context, then this error would be thrown as normal and could be caught some other way.
			throw err;
		}

		//services should return data for the result. e.g. for login, we'll return true for success.
		//if this was instead a service which performed a data search, it would instead return the results for that search.
		return this.app.authenticated;
	}

	async register (payload) {
		/*
			In this sample, we'll make this method more straightforward than the login() since we aren't doing anything special.
		*/
		let auth_token = await this.app.api.register(payload);
		localStorage.setItem('auth_token', auth_token);
		this.app.authenticated = true;
		return true;
	}

	/*
		This is a sample method which might handle "logout". In this sample, we don't need any parameters, we just call the 
		API logout method, set the app.authenticated value to false (which will trigger app.on_unauthenticated()) and
		clear the local storage auth token.
	*/
	async logout () {
		//you might have an API logout method you want to call.
		await this.app.api.logout();
		//set the app.authenticated status to FALSE, this will trigger the app.on_unauthenticated() method if you defined one.
		this.app.authenticated = false;

		//clear the local storage auth_token as well, to be thorough and cleanse the session.
		localStorage.removeItem('auth_token');
		localStorage.clear();
		sessionStorage.clear();

		return this.app.authenticated;
	}
}

//this service would be accessible as...
//app.services.auth.login()

In our above example, we've created an "authentication" service, which handles how to login to the app, register as a user, and how to logout. For each of these, we're accessing methods of the "api" property of the app. You can think of that "api" property as an SDK or package. If you weren't working with an SDK and were connecting to your own back-end, you could put that code within these service methods instead if you wanted to.

Note that this service does not interact with the UI in any way. In fact, it is possible to call these service methods in a completely detached way, without any form submission.

If we wanted to, we could login to the app by doing this: -

app.services.login('username', 'password');

Other parts of the app framework handle detecting when the auth status of the app changes, so this could mean that calling this method would actually trigger the app to refresh over to an authenticated view, from the login page for example. Similarly for logging out; it could be possible to trigger app.services.logout() to force the user out of the application.

Note that the login() method here just throws an error when it is unsuccessful. This should be the intend for all service methods, as the UI workflow which is triggering that service method is instead responsible for what we do with that error (for example, if we want to silently ignore it via a try/catch, or show an error message or modal).

Forms & Handlers

Handlers do not extend any kind of base class, and are fully optional to use in your project; however they are included here with some baked-in functionality to help guide the building of an app with good separations of concerns.

The general Forms behavior is also optional to use; you may use your own form validation and submittal behaviors if you choose.

Handlers are loaded in from any file defined under ./handlers, and become available under app.handlers[handler_name]. When handlers are auto-constructed to be part of app.handlers, it takes a lowercase version of the handler class name, and removes "Handler". Hence, in the example below, "AuthService" becomes "auth".

Handlers connect the UI app and its forms or UX workflows with Services, which perform the logical operations which connect to the back-end.

Handlers are generally bound to a form submit event.

Before we talk more about handlers, we need to discuss the Forms functionality which is native to Celeritas-UI.

Forms are a major part of how a user changes data in a UI application. They can be used for all sorts of purposes, such as logging in, registering, submitting data, changing data, or other operations. An important part of working with forms includes form validation, Celeritas-UI aims to provide an easy interface for handling this.

app.forms.setup(forms = []) can be used to "setup" forms in the UI application. If you are using the Celeritas-UI-Ext package, this occurs automatically when a component or view with a form is rendered. Otherwise, you can call this with no parameters to setup any/all forms on the page, or specific forms by passing in their <form> DOM elements in as an array.

"Setting up" the form tells it how it should behave when it is submitted, and how to handle its validation.

The bahvior of the form is defined via the HTML mark-up of the form. Here is a simple example of a "login" form, and a Handler which pairs with it. You might include this HTML as part of a view template, or component template.

<form id="login" method="post" onsubmit="app.forms.bind(event, 'handlers.auth.login')" novalidate>
	<div class="alerts" data-form-alerts="login"></div>
	<div class="form-group cui-input">
		<label for="email">Email Address <span class="required">*</span></label>
		<input type="email" class="form-control" name="email" placeholder="Email Address" data-required="true" autocomplete="new-password">
	</div>
	<div class="form-group cui-input">
		<label for="password">Password <span class="required">*</span></label>
		<input type="password" class="form-control" name="password" placeholder="Password" data-required="true" autocomplete="new-password">
	</div>
	<div class="text-center">
		<button type="submit" class="btn btn-primary" name="submit">Login</button>
	</div>
	<div class="switch-message">
		<p>Don't have an account? → <a href="#/register">Register</a></p>
	</div>
</form>
class AuthHandler {

	constructor (app) {
		this.app = app;
	}

	/*
		In this sample project, this method is executed when the login form is submitted, because we defined...
		onsubmit="app.forms.bind(event, 'handlers.auth.login')" on the form element for login.

		It will attempt to login via the auth.login service method.

		If it fails, it will return an error/form validation issue.

		If it succeeds, it will show a quick success message and then redirect to the previous route, or dashboard with a 
		500ms delay.
	*/
	async login (payload) {

		try {
			var result = await this.app.services.auth.login(payload.email, payload.password);
		}
		catch (err) {
			//in case you want to do something specific with the error before throwing it
			//however, the forms class handles most form validation, error reporting, alerting, etc. so this likely isn't needed.
			throw err;
		}

		/*
			handlers return an object which describe how the UI should handle the completion of this handler method.
			- message: "an optional message to display"
			- data: the data you want to return
			- route: the URI route you want to redirect the user to after this form handler complete
			- delay: You can optionally add a millisecond delay; this will delay between the message being shown and the route being redirected. 
		
			In this example, after the user has "logged in" we'll show a sample success message, and route them to the previous view they...
			tried to access, or what we want the default view to be "dashboard" in this example.

			The reason we try to route to the previous route first is that maybe the user tried to access a direct URI which requires auth,
			in this case, we preserved that as the previous_route, so after login we can take them back to where they wanted to go.
		*/
		return {
			message: "Welcome!",
			data: result,
			route: Util.get_uri_query().previous_route || 'dashboard',
			delay: 500
		}

	}
}

There are a couple of things to point out here: -

  • On the <form> element, there is an onsubmit behavior set to app.forms.bind(event, 'handlers.auth.login'). What this does, is that when the form is submitted, if all validation passes, it will attempt to execute that handler as defined using the payload of the form inputs.
  • Inputs which are required, are specified with the data-required attribute. If the input is missing data, the form validation will automatically highlight that as a bad input and not proceed with the onsubmit behavior.
  • There is a <div class="alerts" data-form-alerts="login"></div> which is linked to this form via the data-form-alerts attribute. Errors in validation will automatically be shown here (using Bootstrap alerts).
  • If something goes wrong with the service call to log the user in, we simply throw the error from the services. The Forms behavior will automatically show this error as an error alert in the <div> mentioned in the above point. However, you could replace this with your own error handling.

The advantage of the handler here, is that we've defined what we want to do when the form submit succeeds or fails, and the behavior of the form and how that works on the API side is decoupled from the UI itself.

Utilities (Util)

The Celeritas-UI package provides a class of static methods which can be consumed, as Util. Util exposes various helpful methods which the framework library itself uses to handle various things. Primarily these involve manipulating URI routes or parsing data from the query string, searching the DOM for elements, and other helpful tools.

Cache Helper

The Celeritas-UI framework also exposes an easy to use interface for caching data. This cache interface can be combined with services to optimize application performance.

The cache is available via app.cache, and uses the browser session & local storage for implementation.

  • await app.cache.get(key)
  • await app.cache.set(key, data, persist = false)
  • await app.cache.retrieve(key, method, persist = false)

persist defines if local or session storage should be used (session storage would clear when the page session is terminated, whereas local storage persists until removed otherwise).

To use the cache service, you can separately get or set data as you require. However, it is also possible to use the retrieve method, which is effectively a get/set combined. It will attempt to fetch the cache data stored under that key, and if it does not exist, it will use the method defined to retrieve the data and set that for future use.

Here is an example: -

class UsersService {
	constructor (app) {
		this.app = app;
	}

	async get_current_user () {
		/*	
			In this sample, we will fetch the user from the cache if it's stored, otherwise we'll default to getting the
			user data from the API SDK method. 

			We'll use the built in cache manager for this.

			This will first check the local storage "current_user" key; and return JSON parsed data from that if it's available
			If the local storage item with that key doesn't exist, it will use the method defined to implement how it will fetch
			that data fresh when its not available in storage.
		*/

		return await this.app.cache.retrieve(`current_user`, async () => {
			//in this method, we define how we want to get the data if its not available in the cache.
			return await this.app.api.get_current_user();
		}, true);
	}

}

In the above example, if we try to use the UsersService to get the currently signed in user, we can call let user = await app.services.get_current_user();. This will first check if the currently logged in user's details are already available under the cache key current_user in local storage. If it is not, then it will talk to the back-end API directly to fetch the current user. It will save that data to the current_user in local storage before returning that data.

In this way, if we have a view which needs the current user's data as part of its global state to use in each of its components, the view can be defined to fetch the user's data from the UsersService. The UsersService then abstracts out how it should get that user data, either from the cache or fresh from the back-end API directly.

Development & Testing

In order to develop for the Celeritas-UI project, we recommend testing against the sample project as you make changes.

  • Setup the celeritas-ui-core repository on your local machine.
  • Setup the celeritas-ui-ext repository on your local machine.
  • Setup the celeritas-ui-sample repository on your local machine.

For each repository, you will need to run npm install.

After that, you can run node build in the core and ext projects to bundle up the JavaScript and SCSS for these projects. For the sample project, you will need to switch to a feature branch or the dev branch to use the local core and ext packages instead of those from NPM directly. You would run node build dev to build the project locally. From there, you must run it via a web server of your choice.

From here, you can develop changes to the celeritas-ui-core or celeritas-ui-ext projects and see how they impact a fully implemented sample project.

Contribution Guidelines

At this time, this should be considered a private project, we will not be accepting open source contributions.

Author

Readme

Keywords

none

Package Sidebar

Install

npm i celeritas-ui-core

Weekly Downloads

3

Version

0.4.0

License

ISC

Unpacked Size

170 kB

Total Files

17

Last publish

Collaborators

  • the-gazelle