# Models
Models in Nodewood are not the ORM models you may expect. They don't even have to represent a database entity at all - they represent a business logic entity, and that can come from a database, a third-party API, intermediate data, whatever your business logic requires.
# Creating a new Model
From the root of your project, run nodewood add:model FEATURE NAME
. (e.g.: nodewood add:model scheduling calendar
.) This will create a new Model in app/features/FEATURE/lib/models/NAMEModel.js
. Typically, you will pair Models with Validators, so if you have a PostModel
, you'll likely want to pair it with a PostValidator
.
The simplest model looks something like:
const { FieldNumber, FieldString } = require('#lib/Fields');
const SAMPLE_MODEL_FIELDS = {
id: new FieldNumber({ label: 'ID' }),
firstName: new FieldString({ label: 'First Name' }),
lastName: new FieldString({ label: 'Last Name' }),
};
class SampleModel {
constructor({ id, first_name, last_name } = {}) {
super(SAMPLE_MODEL_FIELDS);
this.id = id;
this.firstName = first_name;
this.lastName = last_name;
}
toJSON() {
return {
id: this.id,
first_name: this.firstName,
last_name: this.lastName,
};
}
}
module.exports = {
SampleModel,
SAMPLE_MODEL_FIELDS,
};
# Constructor
The model's constructor accepts a snake-case-keyed object and assigns those values to camel-cased local properties. It also passes the fields for this model to the base Model class to make it easier to display this model's fields.
constructor({ id, first_name, last_name } = {}) {
super(SAMPLE_MODEL_FIELDS);
this.id = id;
this.firstName = first_name;
this.lastName = last_name;
}
Most databases and JSON formats use snake-case, and most JavaScript code is camel-cased, so this helps translate between persistence/transmission formats and your code.
Note that only business logic-related values are saved to the model. The database table your model is based on may have fields like created_at
and updated_at
, but if those fields aren't important to the logic of your application (i.e. only used in sorting when loading from the database), you don't need to save them as model fields. Further, secure fields (like passwords) often shouldn't be stored in models either. They should be accessed securely from within services.
# toJSON()
This function is essentially the opposite of the constructor, taking the camel-cased local properties of the model and turning them back into a snake-case-keyed object for use when saving in a database or sending back from an API.
toJSON() {
return {
id: this.id,
first_name: this.firstName,
last_name: this.lastName,
};
}
# Fields
Models have a MODEL_NAME_FIELDS
variable that describe the fields of that model for display purposes:
const MODEL_NAME_FIELDS = {
id: new FieldNumber({ label: 'ID' }),
firstName: new FieldString({ label: 'First Name' }),
lastName: new FieldString({ label: 'Last Name' }),
};
It is important to note that Fields don't contain the value of the field, they describe it for display. Values for fields are stored in the Model themselves.
Each field is defined with a class from wood/lib/Fields.js
that has different display options available to set in its constructor, and a value()
function you can call with the value of the field to display it appropriately. This makes it easy to loop over a collection of models and just call the value()
function do format it as expected, without needing to store that information in multiple other places in your application.
The base Model class actually has copies of these functions to make it easier to display the model's fields, properly formatted by its fields, as we'll explore in the next section.
# Using fields
To iterate over a list of fields in a model and display the values in a user-friendly way:
const { ExampleModel, EXAMPLE_MODEL_FIELDS } = require('#features/example/lib/models/ExampleModel');
const example = new ExampleModel(someValues);
for (const key of Object.keys(EXAMPLE_MODEL_FIELDS)) {
console.log(`${example.label(key)} ${example.value(key)}`);
}
You can also add classes to the label or data when defining the fields. You can choose whether these classes apply in desktop or mobile mode, and you can choose to apply these classes to the container or inner text. This is useful for certain fields that you want to be displayed centered in their elements, but only on desktop displays:
const EXAMPLE_MODEL_FIELDS = {
approved: new FieldBoolean({
label: 'Approved',
desktopValueClasses: ['text-center'],
valueInnerClasses: ['underline'],
}),
};
<tr v-for="key in Object.keys(EXAMPLE_MODEL_FIELDS)">
<td :class="example.valueClass(key, { desktop: true })">
<span :class="example.valueInnerClass(key, { desktop: true })">
{{ example.value(key) }}
</span>
</td>
</tr>
This will show the "approved" field centered in its container, with the inner text underlined. If you were to pass { mobile: true }
as the second parameter (or omit it entirely), the field would have its inner text underlined only, with no centering.
If you want to display the field's output as HTML, you must set isHtml
in the constructor to true
, otherwise Vue's security model will escape the text instead.
# Custom value class functions
Sometimes you will want to add a class to a field based on that field's value (positive/negative money value, true/false, etc). In these cases, you can define valueClassFn
or valueInnerClassFn
on the field:
const EXAMPLE_MODEL_FIELDS = {
balance: new FieldNumber({
label: 'Balance',
desktopValueInnerClassFn: (value) => value >= 0 ? 'text-green-500' : 'text-red-600',
}),
};
# Different classes for desktop and mobile
Fields will frequently require different styles for display on desktop or mobile mode. For example, the built-in DataTable component displays tables horizontally in desktop mode, and vertically in mobile mode. Centering a field label in mobile mode would look out of place. In order to create a field with different styles in different modes, you can just define those classes when defining the field:
const EXAMPLE_MODEL_FIELDS = {
approved: new FieldBoolean({
label: 'Approved',
desktopValueClasses: ['text-center'],
mobileValueClasses: ['text-right'],
valueClassFn: (value) => value ? 'text-green-600' : 'text-red-500'),
}),
};
Then, when creating the template to display this field, use Tailwind's responsive design utilities (opens new window) to selectively hide and show the differently-styled sections:
<table class="w-3/5 hidden sm:table">
<tr v-for="key in Object.keys(EXAMPLE_MODEL_FIELDS)">
<td :class="example.valueClasses(key, { desktop: true })">
{{ example.value(key) }}
</td>
</tr>
</table>
<table class="w-full m-1 table sm:hidden">
<tr v-for="key in Object.keys(EXAMPLE_MODEL_FIELDS)">
<td :class="example.valueClasses(key, { mobile: true })">
{{ example.value(key) }}
</td>
</tr>
</table>
# List of Field types
The following fields come with Nodewood, but you can create your own by extending { FieldDisplay }
in #lib/Fields
.
# FieldString
The simplest of fields, FieldString displays the field without any modification or formatting.
Code:
const address = new FieldString({ label: 'Address' });
console.log(`${address.label()}: ${address.value('55 West St')}`);
Output:
Address: 55 West St
# FieldNumber
The Number field uses the Numbro (opens new window) library to format the provided value as a number.
Code:
const price = new FieldNumber({
label: 'Price',
numberFormat: {
thousandSeparated: true,
mantissa: 2,
}
});
const day = new FieldNumber({
label: 'Day',
numberFormat: {
output: "ordinal",
},
})
console.log(`${price.label()}: $${price.value(5555.5)}`);
console.log(`${day.label()}: ${day.value(2)}`);
Output:
Price: $5,555.50
Day: 2nd
# FieldBoolean
Use a Boolean field when you have a value that is either true or false, exclusively. The default text for the value is 'Yes' and 'No', but that can be customized.
Code:
const enabled = new FieldBoolean({
label: 'Enabled',
trueValue: 'Indeed',
falseValue: 'Negatory',
});
console.log(`Is ${enabled.label()}: ${enabled.value(true)}`);
console.log(`Isn't ${enabled.label()}: ${enabled.value(false)}`);
Output:
Is Enabled: Indeed
Isn't Enabled: Negatory
# FieldDate
The Date field uses the Moment (opens new window) library to format the provided value as a date. You can also specify the value you wish to display if a date is optional and no date has been specified.
Code:
const openingNight = new FieldDate({
label: 'Opening Night',
dateFormat: 'MMMM Do YYYY, h:mm:ss a',
nullValue: 'Not Scheduled',
});
console.log(`Hamilton ${openingNight.label()}: ${openingNight.value('2015-08-06 20:30:00')}`);
console.log(`Cats - The Return ${openingNight.label()}: ${openingNight.value(null)}`);
Output:
Hamilton Opening Night: August 6th 2015, 8:30 pm
Cats - The Return Opening Night: Not Scheduled
# FieldEnum
Enum fields are like super-powered booleans. However, instead of two defined values, Enums can translate a list of values to a corresponding list of display values.
Code:
const role = new FieldEnum({
label: 'Role',
enumList: {
'leader': 'Team Leader',
'member': 'Team Member',
'w+m1': 'Team Fortress 2',
},
desktopValueInnerClasses: ['text-center'],
});
console.log(`Miyamoto's ${role.label()}: ${role.value('leader')}`);
console.log(`Dave's ${role.label()}: ${role.value('member')}`);
console.log(`Pyro's ${role.label()}: ${role.value('w+m1')}`);
Output:
Miyamoto's Role: Team Leader
Dave's Role: Team Member
Pyro's Role: Team Fortress 2
# FieldEmail
Email fields allow you to display email addresses as links prepended with mailto:
. For example, the email
field in the UserModel
is now a FieldEmail
field, making it easy to quickly fire off an email to a user you need to contact.
This field sets isHtml
to true by default so that the field can properly display HTML output.
Code:
const email = new FieldEmail({ label: 'Email' });
console.log(email.value('hello@email.com'));
Output:
<a href="mailto:hello@email.com">hello@email.com</value>