ui/docs/models.md
We use models primarily as the backing data layer for our forms and for our list/show views. As Ember-Data has matured, our patterns of usage have become outdated. This document serves to outline our current best-practices, since examples within the codebase are often out of date and do not always reflect our best practices or ambitions.
Models can be thought of as the shape of data that an instance of that Model -- a Record -- will have. Models should be as "thin" as possible, holding only data directly relevant to the Record itself. For example, if we have a Model user with attributes firstName and lastName, it is appropriate to have a getter on the Model called fullName, because its attributes can be calculated directly from the record's values, and is relevant to the Record itself. However it is not appropriate to store data like which fields are shown on the edit form, because that has no bearing on the Record itself. Field values are a display concern, not related to the values of the record.
Other patterns and where they belong in relation to the Model:
Attribute metadata - this is referring to information defined on a Model's attributes, such as label, edit type, and other information relevant to both forms and the given attribute. We use these heavily in the FormField component to show the correct label, help text, and input type. Conceptually, this does not belong on a Model (because the information is not directly related to the data in a Record) but, since we leverage OpenAPI heavily to populate both attributes and their metadata, we are going to keep attribute metadata defined on the attribute in the Model. TL;DR: Lives on Model
Form and show fields - the grouping and order of fields that should display on both show routes and create/edit forms, while conceptually related to the Model, is not related to an individual record. Therefore, this information should not be defined on the Model (which has been our previous pattern). To support migration, we have a few helpful decorators and patterns. TL;DR: Lives in component or model-helper util files
Validations - While an argument can go either way about this one, we are going to continue defining these on the Model using our handy withModelValidation decorator. The state of validation is directly correlated to a given Record which is a strong reason to keep it on the Model. TL;DR: Lives on Model
Capabilities - Capabilities are calculated by fetching permissions by path -- often multiple paths, based on the same information we need to fetch the Record data (eg. backend, ID). When using lazyCapabilities on the model we kick off one API request for each of the paths we need, while using the capabilities service fetch method we can make one request with all the required paths included. Our best practice is to fetch capabilities outside of the Model (perhaps as part of a route model, or on a user action such as dropdown click open). A downside to this approach is that the API may get re-requested per page we check the capability (eg. list view dropdown and then detail view) -- but we can optimize this in the future by first checking the store for capabilities of matching path/ID before sending the API request. TL;DR: Lives in route or component where they are used
We use attributes defined on the Model to determine input concerns (label, input type, help text) and field groups to determine the order of the attribute data on the form and detail pages, and are defined in the component they are used in or in a utils/model-helpers/* file.
In this example, we have a Model simple-timer with a few attributes defined. The withExpandedAttributes helper adds a couple items to the Model it's applied to:
OPENAPI_POWERED_MODELS.In the component where we pass a Record of this Model, we can see how we use it to populate either a flat array of attributes for use in the show view, or to populate groups of fields for rendering on a form.
// models/simple-timer.js
@withExpandedAttributes()
export default class SimpleTimer extends Model {
@attr('string', {
editType: 'ttl',
defaultValue: '3600s',
label: 'TTL',
helpText: 'Here is some help text',
})
ttl;
@attr('string') name;
@attr('boolean') restartable; // enterprise only
}
// components/simple-timer-display.ts
export default class SimpleTimerDisplay extends Component<Args> {
@service declare readonly version: VersionService;
// these fields are shown flat in the show mode, iterated over
// and used in InfoTableRow
get showFields() {
let fields = ['name', 'ttl'];
if (this.version.isEnterprise) {
fields.push('restartable');
}
return fields.map((field) => this.args.model.allByKey[field]);
}
// these fields are shown grouped in edit mode and is formatted
// to be used in something like FormFieldGroups
get fieldGroups() {
let groups = [{ default: ['name', 'ttl'] }];
if (this.version.isEnterprise) {
groups[{ 'Custom options': ['restartable'] }];
}
return this.args.model._expandGroups(groups);
}
}
Validations on used on forms, to present the user with feedback about their form answers before sending the payload to the API. Our best practices are:
withModelValidations decoratorvalidate() method added by the decorator on form submitThis decorator:
validate() method on model to check attributes are valid before making an API requesttype keylevel: 'warn' to draw user attention to the input, without preventing form submissionimport { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
// object key is the model's attribute name
password: [{ type: 'presence', message: 'Password is required' }],
keyName: [
{
validator(model) {
return model.keyName === 'default' ? false : true;
},
message: `Key name cannot be the reserved value 'default'`,
},
],
};
@withModelValidations(validations)
export default class FooModel extends Model {
@attr() password;
@attr() keyName;
}
// form-component.js
export default class FormComponent extends Component {
@tracked modelValidations = null;
@tracked invalidFormAlert = '';
checkFormValidity() {
interface Validity {
// only true if all of the state's isValid are also true
isValid: boolean;
state: {
// state is keyed by the attribute names
[key: string]: {
errors: string[];
warnings: string[];
isValid: boolean;
}
}
invalidFormMessage: string; // eg "There are 2 errors with this form"
}
// calling validate() returns Validity
const { isValid, state, invalidFormMessage } = this.args.model.validate();
this.modelValidations = state;
this.invalidFormAlert = invalidFormMessage;
return isValid;
}
@action
submit() {
// clear errors
this.modelValidations = null;
this.invalidFormAlert = null;
// check validity
const continueSave = this.checkFormValidity();
if (!continueSave) return;
// continue save ...
}
}
capabilities-self endpoint, and registered in the store as a capabilities Model, with the path as the Record's ID.kv/data/foo in the admin namespace instead of root)Single capability check within a component In this example, we have an action that some users can take within the page header. Honestly this capability check could just have easily lived in the route's Model (since the PageHeader always renders on the relevant routes), but here it provides a good example of a check happening on component instantiation, using the args passed to the component:
// clients/page-header.js
constructor() {
super(...arguments);
this.getExportCapabilities(this.args.namespace);
}
async getExportCapabilities(ns = '') {
try {
const url = ns
? `${sanitizePath(ns)}/sys/internal/counters/activity/export`
: 'sys/internal/counters/activity/export';
const cap = await this.store.findRecord('capabilities', url);
this.canDownload = cap.canSudo;
} catch (e) {
// if we can't read capabilities, default to show
this.canDownload = true;
}
}
Multiple capabilities checked at once
When there are multiple capabilities paths to check, the recommended approach is to use the capabilities service's fetch method. It will pass all the paths in a single API request instead of making a capabilities-self call for each path as the other techniques do. In this example, we get the capabilities as part of the route's model hook and then return the relevant can* values:
async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`;
const subkeysPath = `${backend}/subkeys/${path}`;
const perms = await this.capabilities.fetch([metadataPath, dataPath, subkeysPath]);
// returns values keyed at the path
return {
metadata: perms[metadataPath],
data: perms[dataPath],
subkeys: perms[subkeysPath],
};
}
async model() {
const backend = this.secretMountPath.currentPath;
const { name: path } = this.paramsFor('secret');
const capabilities = await this.fetchCapabilities(backend, path);
return hash({
// ...
canUpdateData: capabilities.data.canUpdate,
canReadData: capabilities.data.canRead,
canReadMetadata: capabilities.metadata.canRead,
canDeleteMetadata: capabilities.metadata.canDelete,
canUpdateMetadata: capabilities.metadata.canUpdate,
});
}
Lastly, we have an example that is common but a pattern that we want to move away from: using lazyCapabilities on a Model. The lazyCapabilities macro only fetches the capabilities when the attribute is invoked -- so in the example below, only when canRead is rendered on the template will the capablities-self call be kicked off.
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
export default class FooModel extends Model {
@attr backend;
@attr('string') fooId;
// use string interpolation for dynamic parts of API path
// the first arg is the apiPath, and the rest are the model attribute paths for those values
@lazyCapabilities(apiPath`${'backend'}/foo/${'fooId'}`, 'backend', 'fooId') fooPath;
// explicitly check for false because default behavior is showing the thing (i.e. the capability hasn't loaded yet and is undefined)
get canRead() {
return this.fooPath.get('canRead') !== false;
}
get canEdit() {
return this.fooPath.get('canUpdate') !== false;
}
}
In a Model which is hydrated by OpenAPI, it can be cumbersome to keep up with all the changes made by the backend. One pattern available to us is the combineFieldGroups method, which
allFields, formFields and/or formFieldGroups properties on a model classallFields includes every model attribute (regardless of args passed to decorator)formFields and formFieldGroups only exist if the relevant arg is passed to the decoratortype of validator should match the keys in model-helpers/validators.jsimport { withFormFields } from 'vault/decorators/model-form-fields';
const formFieldAttrs = ['attrName', 'anotherAttr'];
const formGroupObjects = [
// In form-field-groups.hbs form toggle group names are determined by key names
// 'default' attribute fields render before any toggle groups
// additional attribute fields render inside toggle groups
{ default: ['someAttribute'] },
{ 'Additional options': ['anotherAttr'] },
];
@withFormFields(formFieldAttrs, formGroupObjects)
export default class SomeModel extends Model {
@attr('string', { ...options }) someAttribute;
@attr('boolean', { ...options }) anotherAttr;
}
{
name: 'someAttribute',
type: 'string',
options: { ...options },
}
// only includes attributes passed to the first argument
model.formFields = [
{
name: 'someAttribute',
type: 'string',
options: { ...options },
},
];
// expanded attributes are grouped by key
model.formFieldGroups = [
{
default: [
{
name: 'someAttribute',
type: 'string',
options: { ...options },
},
],
},
{
'Additional options': [
{
name: 'anotherAttr',
type: 'boolean',
options: { ...options },
},
],
},
];