The full-stack JavaScript framework.
⚠️ Beta Warning- What is Joystick?
- Installation
- Getting Started
- Folder and file structure
- Settings
- @joystick.js/cli
- Databases
- Accounts
- @joystick.js/ui
- @joystick.js/node
- Deployment
Joystick is currently in beta and should not be used for production software. Expect bugs and issues to be common as we work toward a 1.0 release.
Joystick is a full-stack JavaScript framework for building web apps comprised of three NPM packages:
@joystick.js/ui
- A JavaScript library for building user interfaces with HTML, CSS, and JavaScript.@joystick.js/node
- A Node.js library for building back-ends.@joystick.js/cli
- A command-line interface for creating and running Joystick applications and their databases in development.
The first package represents the "front-end" of the stack, while the second represents the "back-end" of the stack. The cli
package ties those two together. While the first two packages can be used independently, they're intended to be and best used together.
Joystick includes:
- A full, pre-configured build system in
@joystick.js/cli
. - A UI component framework in
@joystick.js/ui
. - The ability to run multiple databases in development (and have them wired to your app).
- A pre-configured HTTP server via Express.js in
@joystick.js/node
. - An accounts system that's database agnostic and can map to any of Joystick's supported databases.
- An API layer that includes input validation and the ability to customize the output of responses.
- Built-in server-side rendering of components built with
@joystick.js/ui
and automatic, no code, client-side hydration. - Hot module reloading in development.
To get started with Joystick, install the CLI via NPM:
npm i -g @joystick.js/cli
Note: the -g
flag here is important as you want to install the CLI as part of your computer's global NPM packages.
Node Warning: Joystick is best used with Node.js 16+. If you don't have Node.js installed, or, want to learn how to manage multiple versions on one computer: read this tutorial.
To get started, create a new Joystick app via the CLI:
joystick create <app>
After a few seconds, a folder with the name you pass for <app>
will be created, containing a fresh Joystick app. To start the app, cd
into the folder and run joystick start
:
cd <app> && joystick start
This will build your app and start it at http://localhost:2600
.
Joystick takes an opinionated approach to file structure. It is not designed to be a one-size-fits-all framework which means that the structure of your project must follow Joystick's guidelines.
The file structure below is enforced by Joystick. If it doesn't fit your wants/needs, Joystick isn't for you.
The default structure for a Joystick app consists of the following:
├── /api
│ ├── index.js
├── /email
│ ├── base.html
├── /i18n
│ ├── en-US.js
├── /lib
├── /node_modules
├── /public
│ ├── apple-touch-icon-152x152.png
│ ├── favicon.ico
│ ├── manifest.json
│ ├── service-worker.js
│ ├── splash-screen-1024x1024.png
├── /ui
│ ├── /components
│ ├── /layouts
│ ├── /pages
├── index.client.js
├── index.css
├── index.html
├── index.server.js
├── package.json
├── settings.<env>.json
This gives you everything you need to build a web app while staying organized and consistent with the expectations of Joystick, its community, and the learning materials available at CheatCode (the creator of Joystick).
This folder contains the data API for your web app in the form of a Joystick schema (stored in /api/index.js
) and a series of folders named by resource (e.g., /api/posts
or /api/customers
).
Inside of each resource folder, there should be a getters.js
file and a setters.js
file. Both files export an object containing the getter and setter endpoints that will make up your API.
Inside of /api/index.js
, all of your getters and setters are imported from each resource and added to the main getters
and setters
objects on your schema.
Your /api/index.js
file is then imported in your index.server.js
file and passed as the api
property on your call to node.app()
.
This folder contains the base.html
file for all of the emails in your app and your email templates defined as Joystick components in .js
files (e.g., /email/welcome.js
or /email/reset-password.js
).
This folder contains all of the internationalization or translation files for your app as .js
files using the standard ISO language code as the name (e.g., en-US.js
for English or es-ES.js
for Spanish).
This folder contains all of these miscellaneous/shared functions and data for your application. For example, a function like lib/formatEmailAddress.js
or some generic data like /lib/animals.json
.
All of the currently installed NPM modules for the application.
Any public-facing, static assets for your application like your favicon.ico
file or your app's logo. All files in this folder are mapped to the root /
URL in your application (e.g., /public/favicon.ico
would map to http://localhost:2600/favicon.ico
in development).
This folder contains all of the Joystick components for your app. Components are organized into subfolders depending on their role in your UI:
components
- Miscellaneous components that are used throughout the entire application.layouts
- Components that render fixed UI elements (like navigation), allowing for a dynamic "yield" target that can be populated with the page matching the current route/URL.pages
- Components that are intended to be rendered by your router. A page components consists of some HTML and a composition of other components (e.g., components that live in your/ui/components
directory).
Regardless of type, components should be organized into their own folders containing an index.js
file (e.g., ui/components/toggleSwitch/index.js
or ui/pages/notifications/index.js
).
JavaScript file that's loaded for all pages rendered using the res.render()
function in your routes. Includes miscellaneous JavaScript to run first in the browser when the page loads (e.g., Fathom Analytics script, initializing a Redux store, etc.).
Any global CSS for your entire application. Automatically loaded in the browser for all pages rendered using the res.render()
function in your routes. This CSS is loaded before the CSS for your Joystick components.
The base HTML for your app. Automatically loaded in the browser for all pages rendered using the res.render()
function in your routes. This is where you can load CDN-based libraries or set other global HTML that applies to your entire app.
The JavaScript file loaded by @joystick.js/cli
as the server for your application. This file should contain your instance of node.app()
from the @joystick.js/node
package and any other code you'd like to run on server startup.
Warning: without this file, your app will not work.
The NPM package.json
file which describes the dependencies for your app along with any NPM scripts and other configuration.
Environment specific settings for your app. There are currently four environments supported by Joystick: development
, staging
, production
, and test
.
Joystick anticipates files for each of these environments and loads them based on the value of process.env.NODE_ENV
when your app is started (by default, @joystick.js/cli
automatically sets this to development
on your behalf).
Each file contains the settings intended for that environment only (e.g., settings.development.json
contains your development settings and settings.production.json
contains your production settings).
To aid in configuring your application and supplying settings for things like third-party APIs used in your app, Joystick provides a built-in settings feature.
Settings files follow a standardized structure split into four different groups:
{
config: {},
global: {},
public: {},
private: {},
}
The config
object contains Joystick-specific configuration for your app. The global
object contains settings that should be accessible to both the browser (client) and the server. The public
object contains settings that should be accessible only to the browser. The private
object contains settings that should only be accessible on the server.
When your app starts up, settings are made accessible via the joystick.settings
global object both in the browser and on the server.
When your app starts up, settings are loaded on a per-environment basis, meaning, the file loaded into joystick.settings
is influenced by the current value of process.env.NODE_ENV
. By default, @joystick.js/cli
sets this to development
, meaning the contents of your settings.development.json
will be loaded by default.
There are currently four environments supported by Joystick: development
, staging
, production
, and test
. This means you can have the following files, with each file containing the settings specific to that environment:
settings.development.json
settings.staging.json
settings.production.json
settings.test.json
Any or all of these files should be stored at the root of your project folder. If you store them elsewhere, Joystick will not see them.
Configuration specific to Joystick is stored in the config
object within your settings file. Currently, Joystick anticipates the following properties under config:
databases
an array of objects describing the databases you want Joystick to start for you in development and load drivers for in all environments.i18n
an object containing configuration related to Joystick's internationalization feature.middleware
configuration for the built-in, third-party middleware implemented by Joystick when starting your app.email
configuration for Joystick'semail.send()
function.
For specific configuration properties, see the corresponding section in the documentation below.
Global settings can be stored in the global
object in your settings file:
{
"global": {
"appName": "Spotify"
}
}
Global settings are accessible in the browser and the server.
Public settings can be stored in the public
object in your settings file:
{
"public": {
"googleAnalytics": {
"propertyId": "UA-1234567890"
}
}
}
Public settings are accessible in the browser.
Private settings can be stored in the private
object in your settings file:
{
"private": {
"stripe": {
"secretKey": "sk_test_abcdefg1234567"
}
}
}
Public settings are accessible in the browser.
In development, @joystick.js/cli
is the command line interface that you install globally via NPM. It's used to create new Joystick apps, start an existing app, or build an app for production.
To create a new Joystick project, run:
joystick create <app>
For example, to create an app called "Spotify," run:
joystick create spotify
After this command runs, instructions on next steps will be printed to the console.
To start an existing Joystick project, run the following from the root of the project on your computer:
joystick start
For example, assuming your project lives at ~/projects/spotify
on your computer:
cd ~/projects/spotify && joystick start
joystick start
can be passed two flags:
-e <env>
or --environment <env>
which is the value to set for process.env.NODE_ENV
on startup. For example: joystick start --environment production
.
-p <port>
or --port <port>
which is the value to set for process.env.PORT
on startup and where your app will be accessible on localhost. For example: joystick start --port 1337
to run your app at http://localhost:1337
.
To build an existing Joystick project, from the root of the project, run:
joystick build
For example, assuming your project lives at ~/projects/spotify
on your computer:
cd ~/projects/spotify && joystick build
This will build your application to the .joystick/build
folder at the root of the project.
Note: this feature is not well-tuned for production environments yet. Use with caution and low expectations.
To update an existing Joystick project, from the root of the project, run:
joystick update
For example, assuming your project lives at ~/projects/spotify
on your computer:
cd ~/projects/spotify && joystick update
This will update @joystick.js/node
and @joystick.js/ui
in the project and @joystick.js/cli
globally on your computer.
One of the flagship features of Joystick is the ability to run one or more databases in development and connect the Node.js drivers for those databases to your Joystick app. This allows you to run multiple databases alongside your app simultaneously.
Warning: during the beta release, any databases added using the convention below must be installed by you on your computer. @joystick.js/cli
will provide instructions as necessary in your command line if a database you wish to use it not installed.
Databases are added via the config.databases
array in your settings.env.json
file. As of the current release, MongoDB is the only database supported but support for PostgreSQL and Redis are planned for the 1.0 release.
Joystick starts and connects to databases based on the contents of the config.databases
array. If a database isn't in this array, it will not be started alongside your app and its driver will not be connected in your app.
To specify a database:
{
"config": {
"databases": [
{
"provider": "mongodb",
"options": {}
}
]
}
}
Databases are specified as objects with a minimum of two properties: provider
set equal to a string containing the lowercase name of one of Joystick's supported database providers and an options
object which are the options for the official Node.js driver for that database (loaded and wired into your app by Joystick).
A unique feature of Joystick is that it's set up to map your users to any of Joystick's supported databases. This means that you can store your user's in one database (e.g., PostgreSQL) and the data for your application in another database (e.g., MongoDB).
To specify the database where your users will live, set the users
property on the database's object in the config.databases
array:
{
"config": {
"databases": [
{
"provider": "mongodb",
"users": true,
"options": {}
}
]
}
}
Note: Joystick only supports one users database and will throw an error on startup if more than one database is marked as users: true
in config.databases
.
To add support for MongoDB, add an object to the config.databases
array in your settings file with the following properties:
{
"config": {
"databases": [
{
"provider": "mongodb",
"options": {}
}
]
}
}
Configuration options
for the MongoDB driver can be found in the MongoDB Node.js Driver documentation.
If you do not want Joystick to start a database on your behalf but you do want Joystick to connect the driver for your database to a remote database (running on your computer or in the cloud), pass a connection
object with your database:
{
"config": {
"databases": [
{
"provider": "mongodb",
"options": {},
"connection": {
"username": "username",
"password": "password",
"hosts": [{
hostname: "127.0.0.1",
port: "27017"
}],
"database": "databaseName"
}
}
]
}
}
When you start your app with joystick start
, Joystick will test this connection
to verify it works. If a connection cannot be established, a warning will be printed to your command line.
Joystick includes a basic email address/password accounts system that you can use to create users in your app.
To facilitate in the management of accounts @joystick.js/ui
exports an object accounts
that includes a handful of methods for managing users: accounts.signup
, accounts.login
, accounts.logout
, accounts.authenticated
, accounts.recoverPassword
, and accounts.resetPassword
.
To create a new user account, call accounts.signup()
from a Joystick component.
import ui, { accounts } from "@joystick.js/ui";
const Signup = ui.component({
events: {
"submit form": (event) => {
accounts
.signup({
emailAddress: event.target.emailAddress.value,
password: event.target.password.value,
})
.then(() => {
// Redirect after signup.
location.pathname = "/dashboard";
});
},
},
render: () => {
return `
<form>
<label for="emailAddress">Email Address</label>
<input type="email" name="emailAddress" />
<label for="password">Password</label>
<input type="password" name="password" />
<button type="submit">Signup</button>
</form>
`;
},
});
export default Signup;
If a signup is succesful, two HTTP-only cookies will be created in the user's browser: joystickLoginToken
and joystickLoginTokenExpiresAt
. Once this exists, Joystick will automatically retrieve the user from your users database and include them in every HTTP request to the server.
If you'd like to set a username for your users, this field is supported in addition to the emailAddress
field. This is intentional as it ensures your users always have a means for resetting their password.
import ui, { accounts } from "@joystick.js/ui";
const Signup = ui.component({
events: {
"submit form": (event) => {
accounts
.signup({
emailAddress: event.target.emailAddress.value,
username: event.target.username.value,
password: event.target.password.value,
})
.then(() => {
// Redirect after signup.
location.pathname = "/dashboard";
});
},
},
render: () => {
return `
<form>
<label for="emailAddress">Email Address</label>
<input type="email" name="emailAddress" />
<label for="username">Username</label>
<input type="text" name="username" />
<label for="password">Password</label>
<input type="password" name="password" />
<button type="submit">Signup</button>
</form>
`;
},
});
export default Signup;
Note: when logging in, a username
can be used as an alternative to an emailAddress
if you wish.
If you'd like to use Joystick's i18n (internationalization) feature with your users, you can pass an additional metadata
field containing a language
field with the ISO language code matching your user's preference.
import ui, { accounts } from "@joystick.js/ui";
const Signup = ui.component({
events: {
"submit form": (event) => {
accounts
.signup({
emailAddress: event.target.emailAddress.value,
password: event.target.password.value,
metadata: {
language: "es-ES",
},
})
.then(() => {
// Redirect after signup.
location.pathname = "/dashboard";
});
},
},
render: () => {
return `
<form>
<label for="emailAddress">Email Address</label>
<input type="email" name="emailAddress" />
<label for="password">Password</label>
<input type="password" name="password" />
<button type="submit">Signup</button>
</form>
`;
},
});
export default Signup;
Note: though Joystick checks for the language
field in the metadata object, any field you'd like to add to your user can be set on this object (e.g., metadata.firstName
). Any fields you pass here will be added directly to the user object in the database.
To login to an existing account, pass either an emailAddress
or username
along with a password
to accounts.login()
.
import ui, { accounts } from "@joystick.js/ui";
const Login = ui.component({
events: {
"submit form": (event) => {
accounts
.login({
emailAddress: event.target.emailAddress.value,
password: event.target.password.value,
})
.then(() => {
// Redirect after login.
location.pathname = "/dashboard";
});
},
},
render: () => {
return `
<form>
<label for="emailAddress">Email Address</label>
<input type="email" name="emailAddress" />
<label for="password">Password</label>
<input type="password" name="password" />
<button type="submit">Login</button>
</form>
`;
},
});
export default Login;
If a login is succesful, two HTTP-only cookies will be created in the user's browser: joystickLoginToken
and joystickLoginTokenExpiresAt
. Once this exists, Joystick will automatically retrieve the user from your users database and include them in every HTTP request to the server.
If you'd like to log out an existing user, just call the accounts.logout()
function.
import ui, { accounts } from "@joystick.js/ui";
const Navigation = ui.component({
events: {
"click .logout": (event) => {
accounts.logout();
},
},
render: () => {
return `
<nav>
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/documents">Documents</a></li>
<li><a href="/settings">Settings</a></li>
<li class="logout">Logout</li>
</ul>
</nav>
`;
},
});
export default Navigation;
Once called, Joystick will unset the joystickLoginToken
and joystickLoginTokenExpiresAt
in the user's cookies.
If you'd like to check the authenicated status of a user, call the accounts.authenticated()
function.
import ui, { accounts } from "@joystick.js/ui";
const Navigation = ui.component({
state: {
authenticated: false,
},
lifecycle: {
onMount: async (component) => {
const authenticated = await accounts.authenticated();
component.setState({ authenticated });
},
},
render: ({ state }) => {
return `
<nav>
${state.authenticated ? `
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/documents">Documents</a></li>
<li><a href="/settings">Settings</a></li>
<li class="logout">Logout</li>
</ul>
` : `
<ul>
<li><a href="/login">Login</a></li>
<li><a href="/signup">Signup</a></li>
</ul>
`}
</nav>
`;
},
});
export default Navigation;
Once called, accounts.authenticated()
will return a true
or false
value indicating the user's authentication status.
If a user needs to reset their password, a recovery attempt can be started using the accounts.recoverPassword()
method. This will generate a reset token and add it to the user's passwordResetTokens
array in your users database.
import ui, { accounts } from "@joystick.js/ui";
const RecoverPassword = ui.component({
events: {
"submit form": (event) => {
const emailAddress = event.target.emailAddress.value;
accounts
.recoverPassword({
emailAddress,
})
.then(() => {
window.alert(
`Check your email address at ${emailAddress} for a reset link!`
);
});
},
},
render: () => {
return `
<form>
<label for="emailAddress">Email Address</label>
<input type="email" name="emailAddress" />
<button type="submit">Recover Password</button>
</form>
`;
},
});
export default RecoverPassword;
If SMTP settings are configured in your settings.env.json file at config.email.smtp
, Joystick will attempt to send a password reset email. Note: the URL used in the email assumed that you've added a route at /reset-password/:token
and are rendering a page with a form to perform the reset.
In development, Joystick will automatically console.log() the reset token and URL to your command line for easy access during testing.
Once a password reset token has been received (either via the URL sent in an email, or, logged to the command line in development).
import ui, { accounts } from "@joystick.js/ui";
const ResetPassword = ui.component({
events: {
"submit form": (event, component) => {
const password = event.target.password.value;
const repeatPassword = event.target.repeatPassword.value;
if (password !== repeatPassword) {
window.alert("Passwords must match.");
return;
}
accounts
.resetPassword({
token: component.url.params.token,
password,
})
.then(() => {
location.pathname = "/dashboard";
});
},
},
render: () => {
return `
<form>
<label for="password">Password</label>
<input type="email" name="password" />
<label for="repeatPassword">Repeat Password</label>
<input type="email" name="repeatPassword" />
<button type="submit">Reset Password</button>
</form>
`;
},
});
export default ResetPassword;
If a reset is succesful, two HTTP-only cookies will be created in the user's browser: joystickLoginToken
and joystickLoginTokenExpiresAt
. Once this exists, Joystick will automatically retrieve the user from your users database and include them in every HTTP request to the server.
Joystick ships with a basic roles system for performing authorization checks on users in your database. All roles are stored in the roles
collection/table in your database. Roles granted to users are stored in the roles
array on a user's document/row in the users
database.
Creates a new role, adding it to the roles
collection/table in the database (a convenience so you can see which roles you've granted across all users). You do not have to call accounts.roles.add()
before granting a role to a user. Joystick will automatically add unrecognized roles to the roles
collection/table when they're passed to accounts.roles.grant()
.
import { accounts } from '@joystick.js/node';
export default {
adminCreateRole: {
input: {
role: {
type: "string",
required: true,
}
},
set: (input = {}) => {
return accounts.roles.add(input?.role);
},
},
}
Here, we create a fictitious setter endpoint adminCreateRole
which receives a role to add as an input.
Removes an existing role from the roles
collection/table in the database as well as any users with that role in their roles
array.
import { accounts } from '@joystick.js/node';
export default {
adminDeleteRole: {
input: {
role: {
type: "string",
required: true,
}
},
set: (input = {}) => {
return accounts.roles.remove(input?.role);
},
},
}
Here, we create a fictitious setter endpoint adminDeleteRole
which receives a role to remove as an input.
Returns a list of roles in the roles collection in the database.
import { accounts } from '@joystick.js/node';
export default {
adminGetRoles: {
get: (input = {}) => {
return accounts.roles.list();
},
},
}
Here, we create a fictitious getter endpoint adminGetRoles
which retrieves a list of roles.
Adds a role to the roles
array on a user in the database and to the roles
collection/table if it doesn't already exist there.
import { accounts } from '@joystick.js/node';
export default {
signup: {
input: {
emailAddress: {
type: "string",
required: true,
},
password: {
type: "string",
required: true,
},
role: {
type: "string",
required: true,
}
},
set: async (input = {}) => {
const user = await accounts.signup({ emailAddress: input?.emailAddress, password: input?.password });
await accounts.roles.grant(user?.userId, input?.role);
return user;
},
},
}
Here, we create a fictitious setter endpoint signup
which receives an email address, password, and role for a new user, creating the user and granting them the role passed in the input.
Remove a role from the roles
array on a user in the database.
import { accounts } from '@joystick.js/node';
export default {
demoteManager: {
input: {
userId: {
type: "string",
required: true,
},
},
set: async (input = {}) => {
await accounts.roles.revoke(input?.userId, 'manager');
await accounts.roles.grant(input?.userId, 'employee');
return user;
},
},
}
Here, we create a fictitious setter endpoint demoteManager
which receives a userId
and revokes the manager
role and then grants the employee
role.
Returns true or false as to whether or not a user has a role.
import { accounts } from '@joystick.js/node';
export default {
adminGetRoles: {
authorized: (input, context) => {
return accounts.roles.userHasRole(context?.user?._id, 'admin');
},
get: (input = {}) => {
return accounts.roles.list();
},
},
}
Here, we create a fictitious getter endpoint adminGetRoles
which retrieves a list of roles if the logged in user—available via context.user
—is in the admin role (the getter will only serve the request if the authorized
function returns true
).
@joystick.js/ui
is a standalone package for building user interfaces, designed to work on its own, or ideally, in tandem with @joystick.js/node
.
Components in @joystick.js/ui
can be defined in one way: using the .component()
method defined on the object exported by the package (imported in the examples here as ui
with ui.component()
being the function called to define a component).
import ui from "@joystick.js/ui";
const Books = ui.component({
render: () => {
return `
<div class="books">
<h2>Bookshelf</h2>
</div>
`;
},
});
export default Books;
The most important part of a Joystick component is the render
property, set to a function (arrow optional as JavaScript scope is not utilized) that returns a string of HTML, written using backticks to enable JavaScript string interpolation.
The HTML written in the string is plain HTML. Any HTML that you'd write in a normal .html
file will work and be rendered by Joystick.
To enable advanced functionality in the render
function, JavaScript string interpolation is utilized. This allows for the evaluation (output) and execution (calling) of JavaScript variables and functions from within a component's HTML.
The render
function is passed a single argument as an object: the component instance. This gives you access to the props
and state
for the component as well as some render functions to aid in the rendering process.
Render functions are special functions in Joystick that are passed to the render
function on a component. These help you to nest other Joystick components, render lists of content, conditionally render HTML, or, when using components in conjunction with @joystick.js/node
, render internationalization strings.
Joystick components can be composed together using the component()
render function (alias: c()
):
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ component }) => {
return `
<div class="books">
<h2>Bookshelf</h2>
${component(Book)}
</div>
`;
},
});
export default Books;
Alternatively, an alias of component()
, c()
, is also passed to render
if you wish to use a shorthand version:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ c }) => {
return `
<div class="books">
<h2>Bookshelf</h2>
${c(Book)}
</div>
`;
},
});
export default Books;
Components rendered using the component()
render function can be passed properties as an object which are assigned to the props
property of that component's instance:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ component }) => {
return `
<div class="books">
<h2>Bookshelf</h2>
${component(Book, {
title: "Awareness",
author: "Anthony DeMello",
year: "1992",
})}
</div>
`;
},
});
export default Books;
Any property (or, "prop" if you prefer) you wish can be passed to a component via the component()
render function. Joystick does not modify or limit these values; they behave within the rules of JavaScript without exception.
To render lists of content, Joystick includes an each()
render function:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ each }) => {
return `
<div class="books">
<h2>Bookshelf</h2>
${each(
[
{ title: "Atlas Shrugged", author: "Ayn Rand", year: "1957" },
{ title: "Awareness", author: "Anthony DeMello", year: "1992" },
{
title: "The Rape of the Mind",
author: "Joost Meerloo",
year: "1961",
},
],
(book) => {
return `
<li>${book.title} by ${book.author} (${book.year})</li>
`;
}
)}
</div>
`;
},
});
export default Books;
The first argument to each()
is the array or "list" of items you want to render and the second argument is a function to call for each item in the list. This function—similar to the main render
function for the component—returns a string using backticks, within which any HTML can be rendered, or, render function can be called (e.g., if you wanted to render another Joystick component for each item in the list).
Alternatively, an alias of component()
, c()
, is also passed to render
if you wish to use a shorthand version:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ e }) => {
return `
<div class="books">
<h2>Bookshelf</h2>
${e(
[
{ title: "Atlas Shrugged", author: "Ayn Rand", year: "1957" },
{ title: "Awareness", author: "Anthony DeMello", year: "1992" },
{
title: "The Rape of the Mind",
author: "Joost Meerloo",
year: "1961",
},
],
(book) => {
return `
<li>${book.title} by ${book.author} (${book.year})</li>
`;
}
)}
</div>
`;
},
});
export default Books;
To conditionally render content, the when()
render function can be utilized:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ when }) => {
const books = [];
return `
<div class="books">
<h2>Bookshelf</h2>
${when(
books.length === 0,
`
<p>No books yet.</p>
`
)}
</div>
`;
},
});
export default Books;
Alternatively, an alias of when()
, w()
, is also passed to render
if you wish to use a shorthand version:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ w }) => {
const books = [];
return `
<div class="books">
<h2>Bookshelf</h2>
${w(
books.length === 0,
`
<p>No books yet.</p>
`
)}
</div>
`;
},
});
export default Books;
When using internationalization in @joystick.js/node
, internationalization values (or i18n for short) can be rendered via the i18n()
render function:
import ui from "@joystick.js/ui";
import Book from "../../components/Book";
const Books = ui.component({
render: ({ i18n }) => {
return `
<div class="books">
<h2>${i18n("books")}</h2>
</div>
`;
},
});
export default Books;
Alternatively, an alias of i18n()
, i()
, is also passed to render
if you wish to use a shorthand version:
import ui from "@joystick.js/ui";
const Books = ui.component({
render: ({ i }) => {
return `
<div class="books">
<h2>${i("books")}</h2>
</div>
`;
},
});
export default Books;
When rendering Joystick components, properties or "props" can be passed down to the component and accessed via the component instance.
import ui from "@joystick.js/ui";
const Book = ui.component({
render: ({ props }) => {
return `
<div class="book">
<h2>${props.title} (${props.year})</h2>
<h4>by ${props.author}</h4>
</div>
`;
},
});
export default Book;
Optionally, defaultProps
can be set on a component in the event that a prop you expected to be passed is not:
import ui from "@joystick.js/ui";
const Book = ui.component({
defaultProps: {
title: 'No Title',
year: 'Unknown Year',
author: 'No Author',
},
render: ({ props }) => {
return `
<div class="book">
<h2>${props.title} (${props.year})</h2>
<h4>by ${props.author}</h4>
</div>
`;
},
});
export default Book;
In the example above, if title
, year
, or author
is not defined on props
, Joystick will automatically assign their value to the corresponding value in defaultProps
. Here, if props.title
was undefined, we'd expect it to be set to "No Title."
When rendering Joystick components, state can be used to render arbitrary data and control the display of the component. There are three options for interacting with state in a Joystick component:
- Setting a default value for state via the
state
property on the component. - Setting state dynamically via the
.setState()
method on the component instance. - Reading the current value of
state
via the component instance.
import ui from "@joystick.js/ui";
const Books = ui.component({
state: {
tab: "favorites",
},
events: {
"click [data-tab]": (event, component) => {
component.setState({ tab: event.target.getAttribute("data-tab") });
},
},
render: ({ state }) => {
return `
<div class="books">
<ul class="tabs">
<li data-tab="favorites" class="${
state.tab === "favorites" ? "active" : ""
}">Favorites</li>
<li data-tab="recommended" class="${
state.tab === "recommended" ? "active" : ""
}">Recommended</li>
</ul>
</div>
`;
},
});
export default Books;
Changes to state automatically trigger a re-render.
If you'd like to access props or other parts of the component instance in the state
property, you can assign it as a function returning an object:
import ui from "@joystick.js/ui";
const Books = ui.component({
state: ({ props }) => {
return {
tab: props.defaultTab,
};
},
events: {
"click [data-tab]": (event, component) => {
component.setState({ tab: event.target.getAttribute("data-tab") });
},
},
render: ({ state }) => {
return `
<div class="books">
<ul class="tabs">
<li data-tab="favorites" class="${
state.tab === "favorites" ? "active" : ""
}">Favorites</li>
<li data-tab="recommended" class="${
state.tab === "recommended" ? "active" : ""
}">Recommended</li>
</ul>
</div>
`;
},
});
export default Books;
There are three lifecycle methods (functions that are called at different stages of a component's life):
onBeforeMount
which is called before a component's HTML is mounted to the DOM.onMount
immediately after a component's HTML is mounted to the DOM.onBeforeUnmount
immediately before a component is unloaded from the DOM.
Lifecycle methods can be assigned via the lifecycle
property on a component:
import ui from "@joystick.js/ui";
const Books = ui.component({
lifecycle: {
onBeforeMount: () => {
console.log("About to mount!");
},
onMount: () => {
console.log("Mounted!");
},
onBeforeUnmount: () => {
console.log("About to unmount!");
},
},
render: () => {
return `
<div class="books">
<h2>Books</h2>
</div>
`;
},
});
export default Books;
All lifecycle method functions are passed the component instance as their first argument. Keep in mind: values available to properties on the component instance may vary based on the current stage of the component's lifecycle.
Arbitrary functions can be assigned to a Joystick component and called via the .methods
property on the component instance.
import ui from "@joystick.js/ui";
const Greeting = ui.component({
state: {
name: "Merritt",
},
methods: {
handleSayHello: (component) => {
window.alert(`Hello, ${component.state.name}!`);
},
},
events: {
"click button": (event, component) => {
component.methods.handleSayHello();
},
},
render: () => {
return `
<button>Say Hello</button>
`;
},
});
export default Greeting;
DOM events can be handled by assigning functions via the events
property on a component.
import ui from "@joystick.js/ui";
const Form = ui.component({
events: {
"keyup input": (event) => {
console.log(event.target.value);
},
"click button": () => {
window.alert("Clicked the button!");
},
},
render: () => {
return `
<form>
<input type="text" />
<button>Add name</button>
</form>
`;
},
});
export default Form;
DOM events are assigned by passing a string containing a type of DOM event to listen for, followed by a space, and then a DOM selector (e.g., a class name, an element, etc.) as the key on the events object, assigned a function to call when the event occurs.
In the example above, when a keyup event occurs on the input rendered in the render
function, the function assigned to keyup input
will be called.
All DOM events are automatically scoped to your component.
Styles can be added to the HTML rendered by your component via the css
property.
import ui from "@joystick.js/ui";
const Form = ui.component({
css: `
form {
background: #eee;
padding: 20px;
border-radius: 3px;
}
form input {
border: none;
background: #fff;
padding: 20px;
border-radius: 3px;
border: 1px solid #eee;
}
form button {
background: #333;
border: none;
padding: 15px;
font-size: 16px;
color: #fff;
border-radius: 3px;
}
`,
render: () => {
return `
<form>
<input type="text" />
<button>Add name</button>
</form>
`;
},
});
export default Form;
CSS is dynamically scoped to your component, isolating styles to the component (avoiding issues with cascading or "leaky" styles).
If you need to access the component instance to influence your styles, you can set the css
property to a function returning a string (using backticks to interpolate any values in your CSS).
import ui from "@joystick.js/ui";
const Form = ui.component({
state: {
enabled: true,
},
css: ({ state }) => `
form {
background: ${state.enabled ? "#fff" : "#eee"};
padding: 20px;
border-radius: 3px;
}
form input {
border: none;
background: #fff;
padding: 20px;
border-radius: 3px;
border: 1px solid #eee;
}
form button {
background: #333;
border: none;
padding: 15px;
font-size: 16px;
color: #fff;
border-radius: 3px;
}
`,
render: () => {
return `
<form>
<input type="text" />
<button>Add name</button>
</form>
`;
},
});
export default Form;
If you need to validate a user's form input in your components, @joystick.js/ui
includes a built-in, real-time form validator with several built in validation functions and dynamic error message rendering.
import ui, { accounts } from '@joystick.js/ui';
const Login = ui.component({
events: {
'submit form': (event, component) => {
event.preventDefault();
component.validateForm(event.target, {
rules: {
emailAddress: {
required: true,
},
password: {
required: true,
},
},
messages: {
emailAddress: {
required: 'Email address is required.',
},
password: {
required: 'Password is required.',
}
},
}).then(() => {
accounts.login({
emailAddress: event.target.emailAddress.value,
password: event.target.password.value,
}).then(() => {
location.pathname = '/dashboard';
});
}).catch(() => {});
},
},
render: () => {
return `
<form>
<div class="row">
<div class="col-xs-12 col-lg-5 col-xl-4">
<div class="mb-3">
<label class="form-label">Email Address</label>
<input class="form-control" type="email" name="emailAddress" placeholder="Email Address" />
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" placeholder="Email Address" />
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
Log In
</button>
</div>
</div>
</div>
</form>
`;
},
});
export default Login;
The form validator is accessed via the component instance as component.formValidator()
. It takes two arguments: the DOM element representing the <form></form>
you wish to validate and an options object containing the validation rules and error messages to render if the user's input fails validation.
On the options object, the rules
property is set to an object where each of its properties corresponds to the name
attribute on some input in your form. Set to that property is another object containing the rules for that field.
When component.validateForm()
is called, it will attempt to validate the form per the rules you've specified. If it succeeds, it will resolve the JavaScript Promise it returns (meaning you can handle any post-validation work in the .then()
callback of component.validateForm()
). If it fails, error messages will automatically be rendered beneath the offending inputs and the JavaScript Promise returned is rejected (meaning you can handle any failure in the .catch()
callback of component.validateForm()
).
Currently, component.validateForm()
offers the following validation rules:
Rule name | Possible value(s) | Description |
---|---|---|
creditCard | Boolean true or false |
Validates whether the field's input contains a valid credit card number. |
Boolean true or false |
Validates whether the field's input contains a valid email address. | |
equals | Some value to compare with the user's input (e.g., a string). | Validates whether the field's input is equal to the rule's value. |
matches | Some value to compare with the user's input (e.g., a string). | Validates whether the field's input matches (in value and type) the rule's value. |
maxLength | An integer | Validates whether the field's input is less than or equal to the rule's value. |
minLength | An integer | Validates whether the field's input is greater than or equal to the rule's value. |
phone | Boolean true or false |
Validates whether the field's input is a telephone number. |
postalCode | Boolean true or false or Object with an ISO property as a String and rule property as a Boolean true or false . |
Validates whether the field's input is a postal code (zip code). If defined as an object, regex will be set to the postal code pattern for the specified ISO code. |
required | Boolean true or false |
Validates whether the field's input exists or not. |
semVer | Boolean true or false |
Validates whether the field's input is a semantic version. |
slug | Boolean true or false |
Validates whether the field's input is a slug a-slug-like-this . |
strongPassword | Boolean true or false |
Validates whether the field's input confirms to the isStrongPassword function from the validator dependency used by validateForm . |
url | Boolean true or false |
Validates whether the field's input is a valid URL. |
vat | Boolean true or false or Object with an ISO property as a String and rule property as a Boolean true or false . |
Validates whether the field's input is a valid VAT code. If defined as an object, regex will be set to the postal code pattern for the specified ISO code. |
When using @joystick.js/ui
with @joystick.js/node
, current URL information is available via the url
property on the component instance.
There are three properties available on url
:
url.params
an object containing any route parameters and their values (determined by the route rendering the current component on the server). For example, a route like/posts/:postId
would get a params value like{ postId: 'abc123' }
when visting the URL/posts/abc123
in your app.url.query
an object containing any query parameters from the URL, irrespective of the current route. For example, a URL like/posts?category=featured
would get a query value like{ category: 'featured' }
.url.isActive()
a function that takes a path to compare the current active URL against, returning a booleantrue
if there is a match orfalse
if there is not.
Example usage of url
in a Joystick component:
import ui from "@joystick.js/ui";
const Navigation = ui.component({
render: ({ url }) => {
return `
<nav>
<ul>
<li class="${url.isActive("/") ? "active" : ""}">
<a href="/">Home</a>
</li>
<li class="${url.isActive("/about") ? "active" : ""}">
<a href="/about">About</a>
</li>
</ul>
</nav>
`;
},
});
export default Navigation;
If you need to add a comment for clarification to your code, or, temporarily remove some HTML rendered by your component, you can wrap it with a standard HTML comment (<!-- <code to comment here> -->
):
import ui from "@joystick.js/ui";
const Navigation = ui.component({
render: ({ url }) => {
return `
<nav>
<ul>
<!-- <li class="${url.isActive("/") ? "active" : ""}">
<a href="/">Home</a>
</li> -->
<li class="${url.isActive("/about") ? "active" : ""}">
<a href="/about">About</a>
</li>
</ul>
</nav>
`;
},
});
export default Navigation;
If you're working with third-party packages or trying to manipulate the DOM directly, you may need to access the rendered DOM node that represents your component in the browser. In @joystick.js/ui
, all components are rendered inside of a wrapper <div></div>
with a js-c
attribute set to a unique ID for that component. This <div></div>
represents the "boundary" for your component in the rendered HTML (what Joystick sets as the DOM node).
The DOM node for your component can be accessed via the component instance's DOMNode
property, like this:
import ui from '@joystick.js/ui';
const Map = ui.component({
lifecycle: {
onMount: (component) => {
const map = component.DOMNode.querySelector('#map');
},
},
render: () => {
return `
<div>
<div id="map"></div>
</div>
`;
},
});
The component.DOMNode
property is accessible in all locations where the component
instance is passed, however, it's best utilized in the lifecycle.onMount
method and events
handler methods. The one exception to this is the lifecycle.onBeforeMount
method where the DOM node does not yet exist.
@joystick.js/node
is a standalone package for building a back-end with Node.js and Express.js, designed to work on its own, or ideally, in tandem with @joystick.js/ui
.
The @joystick.js/node
package exports an object containing a app
property equal to a function (typically acessed as node.app()
in code). This function automatically starts an Express.js server, registering your routes and API endpoints in the process.
import node from "@joystick.js/node";
node.app({
api: {
getters: {
posts: {
input: {
category: {
type: "string",
required: false,
},
},
get: (input, context) => {
return context.mongodb.collection("Documents").findOne();
},
},
},
setters: {
createPost: {
input: {
title: {
type: "string",
required: true,
},
},
set: (input) => {
return context.mongodb.collection("posts").insertOne(input);
},
},
},
},
routes: {
"/": (req, res) => {
res.render("ui/pages/index/index.js");
},
},
});
If you need to access the Express.js instance that Joystick creates for you (e.g., for attaching a GraphQL API or a Websocket server), the node.app()
function returns a JavaScript Promise which is passed an object containing the Express.js app
instance and http
server.
import node from '@joystick.js/node';
node.app({
...
}).then((express) => {
// Write any code that utilizes the Express instance here...
});
There are two types of middleware (functions that run before an HTTP request is handed off to one of your matching routes) supported in @joystick.js/node
: built-in middleware and custom middleware that you add on your own.
@joystick.js/node
currently runs the following Middleware on your behalf:
Middleware Name | Version | Package/Proprietary | Settings Path | Description |
---|---|---|---|---|
compression | 1.7.4 | View on NPM | config.middleware.compression | Attempts to compress response bodies for all request that traverse through the middleware. |
serve-favicon | 2.5.0 | View on NPM | N/A | Properly maps all requests for your favicon.ico file to avoid HTTP 404 errors. |
cookie-parser | 1.4.5 | View on NPM | N/A | Parses the HTTP `cookie` header into a JavaScript object and makes it accessible on the Express `req` object. |
body-parser | N/A | View Documentation | config.middleware.bodyParser | Parses the HTTP request `body` into the format specified in the `application/content-type` header. Note: version used is the one bundled with Express.js, not the standalone package. |
cors | 2.8.5 | View on NPM | config.middleware.cors | Aids in the configuration of the CORS (cross-origin resource sharing) policy for the server. This defines what URLs can access the server remotely and blocks unauthorized access. |
Built-in middleware listed in the table above can be configured via your application's settings.<env>.json
file at the root of your project (where <env>
is replaced with the name of the environment those settings apply to).
All built-in middleware can be configured via the config.middleware
object in your settings file (see the "Settings Path" column in the table above for the correct name/path as well as which middleware are configurable):
{
"config": {
...
"middleware": {
bodyParser: {
limit: '50mb',
},
},
...
},
"global": {},
"public": {},
"private": {}
}
For middleware that are configurable, please refer to the documentation for that middleware for configuration options (Joystick just passes these along without modification).
If you wish, custom middleware can be added as an option passed to node.app()
when setting up your Joystick app:
import node from "@joystick.js/node";
node.app({
middleware: [
(req, res, next) => {
// Custom implementation here...
},
someMiddleWarePackage(),
],
});
middleware
should be passed as an array containing functions which expect to be called receiving the standard Express.js route arguments: req
, res
, and next
. Middleware can be from a third-party package, or, a custom middleware that you implement yourself.
Routes are the URLs supported by your application. In Joystick, the only routes you will need to define for your app will be on the server here as part of your node.app()
options (i.e., Joystick does not have a separate client and server router—just one set of routes that rely on the traditional behavior of HTTP).
Routes are defined on the server-side as part of the options you pass to your node.app()
instance, inside of your app's index.server.js
file (generated by @joystick.js/cli
when running joystick create <app>
).
import node from "@joystick.js/node";
node.app({
routes: {
"/dashboard": (req, res) => {
res.send("Dashboard");
},
},
});
By default, all routes are defined as HTTP GET requests using the Express app.get()
method. No changes are made to how Express handles the route, save for a hijacking of the res.render()
method (more on this below) for rendering Joystick components.
If you want to define routes in your app that use another HTTP method other than GET, your route can be defined as an object with an accompanying method
field:
import node from "@joystick.js/node";
node.app({
routes: {
"/newsletter": {
method: "POST",
handler: (req, res) => {
console.log(req.body.emailAddress);
res.send("Subscribed!");
},
},
},
});
If you need to respond to a request to a route based on the user's "logged in" status, you can use the req.context.ifLoggedIn()
helper:
import node from "@joystick.js/node";
node.app({
routes: {
"/login": (req, res) => {
req.context.ifLoggedIn("/dashboard", () => {
res.send("Login");
});
},
},
});
This helper is best read as "if the user is already logged in, redirect them to this route, otherwise, run the code in the callback (i.e., respond to the request as normal)."
If you need to respond to a request to a route based on the user's "logged out" status, you can use the req.context.ifNotLoggedIn()
helper:
import node from "@joystick.js/node";
node.app({
routes: {
"/dashboard": (req, res) => {
req.context.ifNotLoggedIn("/login", () => {
res.send("Dashboard");
});
},
},
});
This helper is best read as "if the user is NOT logged in, redirect them to this route, otherwise, run the code in the callback (i.e., respond to the request as normal)."
As a full-stack framework, @joystick.js/node
is designed to work exclusively with @joystick.js/ui
components. The res.render()
method is the "magic" that connects the two together.
In order to render a Joystick component that represents a page (typically, placed in the ui/pages
directory in your app), call res.render()
passing the full, relative path to your component:
import node from "@joystick.js/node";
node.app({
routes: {
"/dashboard": (req, res) => {
res.render("ui/pages/dashboard/index.js");
},
},
});
When this code runs, Joystick will automatically server-side render the component at that path and bundle the necessary JavaScript files together, injecting the end result into your app's index.html
file and responding to the request with the resulting HTML.
As part of the server-side rendering process, Joystick will automatically build out the scoped CSS for your components and inject it into the <head></head>
tag for your HTML to ensure users get the experience you intend on first paint in the browser (read: no Flash of Unstyled Content).
As part of Joystick's file structure, you will find a /ui/layouts
folder. This folder should contain Joystick components that represent layouts for your app. A layout is a "wrapper" component that contains always-visible elements like a navigation bar or footer with a space to render the contents of the current page.
import node from "@joystick.js/node";
node.app({
routes: {
"/dashboard": (req, res) => {
res.render("ui/pages/dashboard/index.js", {
layout: "ui/layouts/authenticated/index.js",
});
},
},
});
In this example, we assume a ui/layouts/authenticated/index.js
file exists which is the layout used when a user is logged in or authenticated (just an example here; this is NOT built in to Joystick). In that file, we'd expect to see something like this:
import ui from "@joystick.js/ui";
const Authenticated = ui.component({
render: ({ props, url, component }) => {
return `
<nav>
<ul>
<li class="${url.isActive("/dashboard") ? "active" : ""}">
<a href="/dashboard">Dashboard</a>
</li>
<li class="${url.isActive("/documents") ? "active" : ""}">
<a href="/documents">Documents</a>
</li>
<li class="${url.isActive("/settings") ? "active" : ""}">
<a href="/settings">Settings</a>
</li>
</ul>
</nav>
${component(props.page, props)}
`;
},
});
export default Authenticated;
Here, we anticipate a props.page
prop to be passed to the layout by Joystick (this contains the component passed to res.render()
when a layout
is set). Notice that we re-use the standard component()
render function to output the respective page into the layout.
Initial props to include in the server-side rendered HTML returned by res.render()
can be passed via the options object.
import node from "@joystick.js/node";
node.app({
routes: {
"/dashboard": (req, res) => {
res.render("ui/pages/dashboard/index.js", {
props: {
stats: [
{ value: "100", label: "Active Customers" },
{ value: "$28,798", label: "30-Day Revenue" },
{ value: "3", label: "Open Support Requests" },
],
},
});
},
},
});
For pages rendered with res.render()
that require custom SEO metadata in the <head></head>
tag of the rendered HTML, the head
property can be leveraged in the options object passed to res.render()
. The head
property supports three properties: title
, tags
, and jsonld
.
title
The head.title
property contains the value to be set in the <title></title>
tag inside of the <head></head>
tag.
tags
The head.tags
property contains an object supporting three properties: meta
, link
, and script
. Each property is set to an array of objects, with each object representing the HTML attributes to set on a tag of that type (e.g., objects in the head.tags.meta
array represent <meta />
tags to add to the <head></head>
).
jsonld
The head.jsonld
property contains an object containing JSON-LD properties to be rendered into a <script></script>
tag with a type
attribute equal to application/ld+json
.
node.app({
api,
routes: {
"/recipes": (req, res) => {
res.render("ui/pages/recipes/index.js", {
layout: "ui/layouts/site/index.js",
head: {
title: "Sushi Recipes",
tags: {
meta: [
{ name: 'description', content: 'Recipes for preparing authentic Japanese sushi at home.' },
],
link: [
{ rel: 'stylesheet', href: '/fonts.css' },
],
script: [
{ src: 'https://kit.fontawesome.com/d91Zfc923L.js', crossorigin: 'anonymous' },
],
},
jsonld: {
"@context": "https://schema.org/",
"@type": "Recipe",
name: "Sushi Recipes",
author: {
"@type": "Person",
name: "Oliver Nguyen",
},
datePublished: "2021-11-10",
description:
"Recipes for preparing authentic Japanese sushi at home.",
},
},
});
},
},
});
When a user visits /recipes
in the example above, the data in the head
object will be automatically rendered into the <head></head>
tag of the HTML for that page.
To simplify the process of defining an API, Joystick includes a thin abstraction layer over Express.js for creating endpoints. It's based on the idea that all data in your application is either you "getting" something or "setting" something in a database.
Following this logic, Joystick introduce a concept called "getters" and "setters." The former helps you define HTTP GET endpoints for reading data from your database while the latter helps you define HTTP POST endpoints for creating, updating, and removing data from your database.
A getter is nothing more than a JavaScript object set to a property with the name of the getter you'd like to make accessible on the server.
export default {
posts: {
input: {
category: {
type: "string",
},
},
get: async (input, context) => {
const query = {};
if (input.category) {
query.category = input.category;
}
const posts = await context.mongodb.collection("posts").find().toArray();
return posts;
},
},
};
Here, we define a getter called posts
with two properties: input
, set to an object that defines validation for any input values passed from the browser when calling the getter and get
, a function which receives the validated input
as its first argument and a context
object as the second argument.
A getter is intended to retrieve and return data. Once inside the body of the get()
function, you can retrieve your data from any data source you wish (typically a database, but could also be from a third-party API or a static data file on the server).
Once your data is retrieved, you return it from the get()
function and Joystick sends it back to the originating request as a JSON body.
A setter is nothing more than a JavaScript object set to a property with the name of the setter you'd like to make accessible on the server.
export default {
createPost: {
input: {
title: {
type: "string",
required: true,
},
},
set: async (input, context) => {
const postId = joystick.id();
await context.mongodb.collection("posts").insertOne({
_id: joystick.id(),
...input,
});
return {
_id: postId,
};
},
},
};
Here, we define a setter called createPost
with two properties: input
, set to an object that defines validation for any input values passed from the browser when calling the setter and set
, a function which receives the validated input
as its first argument and a context
object as the second argument.
A setter is intended to create, update, and delete data, and optionally return data. Once inside the body of the set()
function, you can call to any data source you wish (typically a database, but could also be from a third-party API or a static data file on the server).
Once your data is set, you can optionally return a value from the set()
function and Joystick will send it back to the originating request as a JSON body.
Joystick uses a built-in validation library for validating inputs passed from the browser to the server as part of a call to a getter or setter.
Validation rules are defined using a nested object structure (written to mimic the structure of the data you're passing from the browser) along with a few different properties to set the rules for your inputs.
A validation object looks like this:
{
title: {
type: "string",
required: true,
}
}
Here, title
is the name of the field we want to validate. We expect it to have a data type of string
and require it to be passed in the input.
The object assigned to the field name is known as the validator, while the properties on that object are known as the rules for that validator.
Currently, a validator can define the following rules:
Rule name | Values | Description |
---|---|---|
allowedValues | An array of values as: strings, integers, floats, or booleans. | An enumerable (enum) list of values that are allowed to be passed for the field. |
element | A validator object that describes the contents of each item in an array. | Only required when `type` is equal to `array`. Defines the shape of an element expected in the array. |
fields | A validator object that describes the contents of an object. | Only required when `type` is equal to `object`. Defines the shape of an object passed as the value for an input field. |
max | A maximum value expressed as an integer or float. | Set a maximum value for the field as a number (integer or float). |
min | A minimum value expressed as an integer or float. | Set a minimum value for the field as a number (integer or float). |
optional | A boolean `true` or `false`. | Specifies whether or not a field is optional. If set to `false`, a value is required. |
regex | A regular expression expressed as a `/thing/g` regular expression, or, a JavaScript RegExp object. | Validates whether the field value conforms to the regular expression. |
required | A boolean `true` or `false`. | Specifies whether or not a field is required. If set to `false`, a value is optional. |
type | As a JavaScript string, one of: any ,array , boolean , float , integer , number , object , or string . |
The expected type of data for the field to contain. |
Validator objects can be composed together to create complex validation. For example, consider the following input:
input: {
name: "Trent Rezor",
instruments: ["piano", "guitar", "vocals", "kazoo"],
albums: [
{ title: 'The Downward Spiral', year: '1994' },
{ title: 'The Fragile', year: '1999' },
{ title: 'Broken EP', year: '1995' },
],
},
The validation for this could take shape as:
input: {
name: {
type: "string",
required: true,
},
instruments: {
type: "array",
allowedValues: ["piano", "guitar", "vocals", "bass"],
},
albums: {
type: "array",
element: {
type: "object",
fields: {
title: {
type: "string",
required: true,
},
year: {
type: "string",
optional: true,
},
},
},
},
},
While your data should be kept shallow for the sake of clarity and simplicity, Joystick's validation can technically be nested infinitely as it runs recursively to an arbitrary depth.
Depending on your app, you may need to authorize access to your API conditionally. To do this, all getters and setters in Joystick support an authorized()
function which will return an HTTP 403 Fordbidden error to the original request when returning false
:
export default {
createPost: {
input: {
title: {
type: "string",
required: true,
},
},
authorized: (input, context) => {
return !!context?.user;
},
set: async (input, context) => {
const postId = joystick.id();
await context.mongodb.collection("posts").insertOne({
_id: joystick.id(),
...input,
});
return {
_id: postId,
};
},
},
};
The authorized()
function receives two arguments: the validated input
for the getter or setter request and the context
object for the request (identical to the get()
and set()
function of the getter or setter itself).
If the function returns a Boolean true
, the request runs as normal. If the function returns a Boolean false
, the request is rejected and returns an HTTP 403 Forbidden error along with a "Not authorized to access" error message.
The schema is the name for the object where you load all of your app's getters and setters. This should be exported from the /api/index.js
file. A schema object has two properties: getters
and setters
.
export default {
getters: {
posts: {
input: {
category: {
type: "string",
},
},
get: async (input, context) => {
const query = {};
if (input.category) {
query.category = input.category;
}
const posts = await context.mongodb
.collection("posts")
.find()
.toArray();
return posts;
},
},
},
setters: {
createPost: {
input: {
title: {
type: "string",
required: true,
},
},
set: async (input, context) => {
const postId = joystick.id();
await context.mongodb.collection("posts").insertOne({
_id: joystick.id(),
...input,
});
return {
_id: postId,
};
},
},
},
};
While you can certainly define all of your getters and setters in the schema file, it's recommended to separate them off into their own files, organized by folders named after the resource they relate to:
import postGetters from "./posts/getters.js";
import postSetters from "./posts/setters.js";
export default {
getters: {
...postGetters,
},
setters: {
...postSetters,
},
};
Above, we assume that postGetters
and postSetters
are objects and use the JavaScript spread operator ...
to "unpack" those objects on to the getters
and setters
objects on the schema.
Your schema is loaded into your app by adding it as a property on the options passed to your node.app()
instance:
import node from '@joystick.js/node';
import api from './api/index.js';
node.app({
api,
routes: {
'/': (req, res) => { ... }
},
});
If you do not pass api
as a property to node.app()
your schema will not be loaded in the app.
Although getters are defined as Express.js routes that you can call via fetch()
or another HTTP library, it's easier and recommended to use @joystick.js/ui
's built-in get()
method to perform your get request.
import ui, { get } from "@joystick.js/ui";
const Profile = ui.component({
state: {
name: "",
age: "",
location: "",
},
lifecycle: {
onMount: (component) => {
get("profile", {
output: ["name", "age", "location"],
}).then((data) => {
component.setState({
name: data.name,
age: data.age,
location: data.location,
});
});
},
},
render: ({ state }) => {
return `
<div>
<p>Name: ${state.name}</p>
<p>Age: ${state.age}</p>
<p>Location: ${state.location}</p>
</div>
`;
},
});
export default Profile;
When using the get()
method, you pass the name of the getter as defined on the server as string for the first argument, followed by an options object as the second argument.
The options object accepts two properties: input
and output
. input
collects the values from the UI as an object that you want to send the server and includes them as query params on the resulting HTTP GET request. output
takes an array of strings using JavaScript dot notation like.this
that specify the data and the shape of that data you expect in return.
For example, assuming our profile
getter returned a value like this:
{
name: 'Trent Reznor',
age: '52',
location: 'Los Angeles, California',
band: 'Nine Inch Nails',
favoriteMusician: 'David Bowie',
}
Using the output
array in the example get()
request above, we'd only get back:
{
name: 'Trent Reznor',
age: '52',
location: 'Los Angeles, California',
}
This means that you can write multi-purpose getters and tailor the output of those getters based on the current UI without having to wire up an additional endpoint.
Although setters are defined as Express.js routes that you can call via fetch()
or another HTTP library, it's easier and recommended to use @joystick.js/ui
's built-in set()
method to perform your set request.
import ui, { set } from "@joystick.js/ui";
const Profile = ui.component({
state: {
name: "",
age: "",
location: "",
},
events: {
"submit form": (event) => {
set("updateProfile", {
input: {
name: {
first: event.target.firstName.value,
last: event.target.lastName.value,
},
},
output: ["name.first"],
}).then((data) => {
// Expect only data.name.first to be defined based on the output array above.
component.setState({
name: data.name,
});
});
},
},
render: ({ state }) => {
return `
<form>
<label for="firstName">First Name</label>
<input type="text" name="firstName" />
<label for="lastName">Last Name</label>
<input type="text" name="lastName" />
</form>
`;
},
});
export default Profile;
When using the set()
method, you pass the name of the setter as defined on the server as string for the first argument, followed by an options object as the second argument.
The options object accepts two properties: input
and output
. input
collects the values from the UI as an object that you want to send the server and includes them as body params on the resulting HTTP POST request. output
takes an array of strings using JavaScript dot notation like.this
that specify the data and the shape of that data you expect in return.
As part of the get()
and set()
methods included in @joystick.js/ui
, an option exists to control the output
of calls to those functions using a technology we refer to as SelectiveFetch.
SelectiveFetch allows us to tailor the output from a getter or setter to meet the needs of our UI. This means that we can define endpoints that return large sets of data, however, can be scaled down to fit the needs of each unique UI without having to wire up additional endpoints.
To utilize SelectiveFetch, as part of the options object passed to a get()
or set()
call, include the output
property, set to an array of strings describing the data you want to get back in return.
For example, imagine one of our getters returned data like this:
{
profile: {
get: () => {
return {
name: {
first: 'Max',
last: 'Keiser',
},
emailAddress: '[email protected]',
bitcoinAddress: 'mqW5mA5gghdTa9QcEn8aW4FcLAJWctWVVZ',
address: {
street: '1234 Fake St.',
city: 'Wherever',
state: 'NY',
zipCode: '10001',
},
};
},
}
}
If our UI only called for the bitcoinAddress
field and the zipCode
under address
, returning the entire response would be wasteful. Using SelectiveFetch, we can tailor this output to only the fields we need:
get("profile", {
output: ["bitcoinAddress", "address.zipCode"],
});
That's it. Now, when Joystick runs the get()
request, it will take the response it receives and filter it down to only these two fields, giving us something like this:
{
bitcoinAddress: 'mqW5mA5gghdTa9QcEn8aW4FcLAJWctWVVZ',
address: {
zipCode: '10001',
},
}
All of the other data is omitted.
If you're building an app for a multi-lingual audience, Joystick helps you by connecting translations you author on the server with the Joystick components in your UI.
Translations should be placed in the /i18n
folder at the root of your project. In this folder, .js
files should be added with names matching the ISO code for the language you'd like to support, for example: en-US.js
.
Inside of that file, Joystick expects a JavaScript object to be exported with properties set to paths matching one of the components in your /ui
folder:
export default {
"ui/pages/index/index.js": {
quote: `There is no excuse to not endlessly continue to try and make everything that you want to do as wonderful as your vision.`,
attribute: "Jeremiah Tower",
},
};
Here, we have a page component at ui/pages/index/index.js
that we want to define translations for. Note: this is important. When using the res.render()
function, Joystick will automatically try to match the path you pass to that function with one of the properties (e.g., ui/pages/index/index.js
) in the active translation file. If there's a match, it will load the translation object for that specific page. If it cannot find a match, it will load the entire translation file.
Keep in mind: paths are absolute and must specify the full path in order to work.
Translations loaded when using the res.render()
function to render a page can be accessed by utilizing the i18n()
render function in a Joystick component.
import ui from "@joystick.js/ui";
const Index = ui.component({
render: ({ i18n }) => {
return `
<blockquote>
<p>${i18n("quote")}</p>
<p>— ${i18n("attribute")}</p>
</blockquote>
`;
},
});
export default Index;
If there was a matching path for the page rendered in your language file, Joystick will load it as the "root" or starting point for the i18n()
function. Above, passing quote
or attribute
to the i18n()
function as a string is equivalent to saying ui/pages/index/index.js.quote
or ui/pages/index/index.js.attribute
.
Again, keep in mind: if there was not a matching path in your language file, this short-hand will not work. Instead you will have to specify the full path to the translation you want to render.
If for whatever reason you prefer this functionality, you can author your language file like this:
export default {
Index: {
quote: `There is no excuse to not endlessly continue to try and make everything that you want to do as wonderful as your vision.`,
attribute: "Jeremiah Tower",
},
};
And then load the translations into your UI like this:
import ui from "@joystick.js/ui";
const Index = ui.component({
render: ({ i18n }) => {
return `
<blockquote>
<p>${i18n("Index.quote")}</p>
<p>— ${i18n("Index.attribute")}</p>
</blockquote>
`;
},
});
export default Index;
While Joystick does its best to handle Node.js process events on the server, it can be helpful to be able to "plug in" to these events and implement custom logic. As part of the node.app()
function in @joystick.js/node
, an events
object can be passed with functions/methods for handling specific Node.js process events:
import node from "@joystick.js/node";
import api from "./api";
node
.app({
api,
events: {
error: (error) => {
console.log(error);
},
beforeExit: (error) => {
console.log("beforeExit", error);
},
disconnect: (error) => {
console.log("disconnect", error);
},
exit: (error) => {
console.log("exit", error);
},
message: (error) => {
console.log("message", error);
},
rejectionHandled: (error) => {
console.log("rejectionHandled", error);
},
uncaughtException: (error) => {
console.log("uncaughtException", error);
},
uncaughtExceptionMonitor: (error) => {
console.log("uncaughtExceptionMonitor", error);
},
unhandledRejection: (error) => {
console.log("unhandledRejection", error);
},
warning: (error) => {
console.log("warning", error);
},
worker: (error) => {
console.log("worker", error);
},
},
routes: {
...
},
})
.then((express) => {
// console.log(express);
});
The list of events in this example (and their names) represent the full list of events that you can listen for through Joystick. Of course, you can still manually tap into the Node.js process
directly if you need access to other events, or, prefer a DIY approach to handling.
Because @joystick.js/node
uses the --experimental-modules
flag to enable ES Module support in Joystick, the __filename
and __dirname
global variables you expect to have access to in a Node.js app are unavailable. To supplement, @joystick.js/node
includes two polyfill functions for mimicing the behavior of these variables: __filename()
and __dirname()
.
import { __filename, __dirname } from '@joystick.js/node';
console.log(__filename(import.meta.url));
// Absolute path to the current file in your .joystick/build directory.
console.log(__dirname(import.meta.url));
// Absolute path to the current directory in your .joystick/build directory.
Here, when you call the functions, you need to pass the global import.meta.url
value from Node which describes the current path of the file where import.meta.url
is utilized. If you do not pass import.meta.url
to either function, it will return an empty string.
An official strategy for deployment is in the works and will be available in time for the 1.0 release.