Blueprint
Blueprint is a paradigm that lets you write schema code and it can generate code while at the same time giving you the ability to add features, modify code and also support additional functionalities.
Because Blueprint outputs an X-Framework compatible project, it's important that you have you are up to date with the X-Framework basics.
To get directly into try-ing it:
npm i -g @bluelibs/xx
Choose x:project
and create your new project:
cd projectnpm install
You will notice a blueprint/index.ts
file which only has Users
collection setup. To generate the project:
npm run blueprint:generate
This will generate:
- Server-side:
- Collections
- Models
- GraphQL Types
- GraphQL CRUDs
- Queries and Mutations for CRUDs
- Custom GraphQL Inputs
- Subscription & Live Data Ready
- Client-side:
- UI Collections
- For Isomorphic Interaction with MongoDB
- CRUD Admin Pages
- Lists with Search, Pagination and Complex Filters
- Edit Forms
- Create Forms
- Easy Deletion
- UI Collections
note
You can use the x
command to generate collections and shared models inside Blueprint: blueprint:collection
and blueprint:shared-model
Collections
Let's start from scratch with a simple blueprint to walk you through the main concepts
import { Studio } from "@bluelibs/x";import * as faker from "faker";
const { generateProject, app, collection, field, relation, sharedModel } = Studio;
const application = app({ id: "my-app", collections: [ // Here you will store your collections collection({ id: "Posts", description: "What is this collection for", // optional fields: [ // Here we specify the fields for collection field({ id: "title", type: fields.types.STRING, }), ], }), ],});
// This is what triggers the generation of the projectgenerateProject(application);
note
Sometimes as your app grows you might want to generate things only for specific set of collections, or you're playing around with one collection and you don't want it to affect other files, to do that:
You can use booleans skip:true
, only:false
properties in collections objects, or introduce array of strings skip=['Users']
,only=["Posts"]
of collections ids in the app object.
By default all collections and fields are ui
aware and exposed in the GraphQL API. To disable this behavior for collections or fields:
collection({ id: "Posts", ui: false, enableGraphQL: false,});
GraphQL
We generate GraphQL entities (models reflecting the ones from the database) and GraphQL Module CRUDs which allow us to interact easily with the server.
If you do not want to have CRUD only, but you need the Entity because it's maybe used by some other entites:
collection({ // ... enableGraphQL: { entity: true, crud: false, },});
// Using enableGraphQL: true, will enable both// Using enableGraphQL: false, will disable both
note
Having entity
false, will not create UI Collections, because those collections would have interacted with the CRUD endpoints created.
Behaviors
This refers to the capability to add Collection Behaviors from MongoBundle
Be careful with behaviors as they are generated only once, if you want to add a specific behavior after you generated, you either delete the collection file, either add it yourself manually to the collection.
collection({ id: "Users", behaviors: { timestampable: true, softdeletable: true, },});
Typically these behaviors also have shortcuts so you can easily configure things like blameable
relations or timestampable
fields.
UI
To customise UI for Collection, like enable create, but disable edit, here is the full config of UI for the collection:
collection({ id: "Posts", ui: { label: "Posts", order: 1, // optional if you want to have a specific order for elements icon: "BankOutlined", // from: https://ant.design/components/icon/ list: true, edit: true, create: true, view: true, delete: true, }, enableGraphQL: false,});
Representation
Think of each document having a toString()
representation of itself. For example User
would have fullName
, a Post would maybe have title
. We need to specify how we represent these collections because when we perform things such as relational viewing or selecting a User
from a select autocompleted box, to see it properly:
collection({ id: "Posts", representedBy: "title", // the field title needs to be inside fields[]});
Now when we relate collections to each other, as you'll see a bit below, you can optionally specify a different representation, otherwise it will default to this one.
Note that these fields can also be reducers (again, a concept you will learn below)
Fields
Fields are essential to describing how your collection model looks like, inside fields we can store relational data (ids from other collections) or any other supported primitive. Fields can also have subfields
which translates in the output as a nested object.
The typical field looks like this:
field({ id: "variableName", type: field.types.STRING,
// Optionals description: "What is this field for?", // This would be stored as a comment for both models and 'GraphQL' entities and will be shown as helper text inside the forms isArray: false, // (default: false) whether it's an array of elements isRequired: true, // (default: true) enableGraphQL: true, // (default: true) would this field be present in the API? For example if you store a password hash, then most likely not. // Disable all UI by saying ui: false ui: { label: "Variable Name", // How it is presented in the field order: number, list: true, // Whether this is presented in the list table of elements listFilters: true, // Whether you can filter by it view: true, // Whether it's present in the view edit: true, // Whether it's present in the edit form create: true, // Wheter it's present in the create form, form: { component: "Input.TextArea", // This supports every data entry from Ant props: {}, // Additional props to pass the component like sizes or etc. }, },});
The available field types:
export enum FieldValueKind { OBJECT_ID = "objectId", STRING = "string", ENUM = "enum", INTEGER = "integer", FLOAT = "float", DATE = "date", BOOLEAN = "boolean", OBJECT = "object",}
Shortcuts
// Making it super straight forward to avoid { id, type }field.string("firstName");// Add additional options to itfield.string("lastName", { isRequired: true });
// And the rest of the crowdfield.objectId("id");field.enum("id");field.integer("id");field.float("id");field.date("id");field.boolean("id");field.object("id");
Default Values
Default values end-up in your models, your inputs and your forms. They are easy and straight forward to work with. We currently support any JSON
compatible object and Date
.
field.string("firstName", { defaultValue: "John", /// 123, true,});field.date("createOn", { defaultValue: new Date(),});
Nested Fields
You can benefit of nested fields which act as sub models, don't worry, the form will properly generate your code so you don't have to think about it.
field({ id: "profile", type: field.types.OBJECT, subfields: [ field({ id: "firstName", type: field.types.STRING, }), field({ id: "lastName", type: field.types.STRING, }), ],});
Assuming we add this field in Users
collection, this would create a new type in GraphQL UserProfile
and a new class with validation UserProfile
, you don't even have to think about it. And ofcourse it works with arrays too.
Enums
Enums are great especially when your field belongs to a set of values:
field({ id: "status", type: field.types.ENUM, enumValues: [`IN_PROGRESS`, `ACTIVE`, `CLOSED`],});
Assuming we add this field in Tasks
collection it will create a proper TS enum: TaskStatus
and inside the forms it will be a select dropdown option. You can also make it an array.
Enums can also be configured better, especially if you want to add them a comment or modify their database-store value:
field({ id: "status", type: field.types.ENUM, enumValues: [ { id: "IN_PROGRESS", value: "IP", // this gets saved in db label: "In Progress", // this is how it shows on the UI and creates the propper lables description: "This means the the task is in progress", // gets a comment in TS model and GraphQL API }, ],});
Reducers
Reducers are Nova's way of computing values based on certain dependencies:
field({ id: "fullName", type: field.types.STRING, isReducer: true, reducerDependency: { profile: { firstName: 1, lastName: 1, }, },});
Here comes an interesting thing. Reducers will be stored inside collections/Users/Users.reducers.ts
, however the logic, the actual function, you'll have to write it yourself in there. This won't be overriden on subsequent generations as it will check if it exists. This is an intentional limitation of Blueprint, as we don't want to gobble it with a lot of functionality, plus, reducers can get very complex, however it's important to have them here because they can appear inside Lists, View, and when you are selecting remote data (example you want to assign an Invoice to a User, and you display fullName
of those users)
Relations
Relations are described the same way Nova relations are, they are stored at collection level under relations: []
, like this:
app({ collections: [ collection({ id: "Comments", fields: [], relations: [ // By default it will create the field `postId` of type ObjectID // If you add `isMany: true` to the config, it will create `postIds` of type Array<ObjectID>` relation({ id: "post", to: "Posts", }), ], }), collection({ id: "Posts", fields: [], // some fields relations: [ relation({ to: "Comments", inversedBy: "post", // the "id" of the other's side link }), ], }), ],});
If you understand how Nova works, it should be straight forward to understand how these links are described.
Keep in mind, once a link is generated, it won't be able to properly update it. We do not override the .links.ts
file. So if, let's say you want to change post
link from Comments
to isMany: true
, this change won't be present in the generated code, you'll have to manually do it, or delete the file.
There are many reasons for this, one is the fact that Nova offers a lot of complex additional features to linking data such as Filtered Links and as with reducers we don't want to gobble up the blueprint, we believe this is a reasonable limitation. If you add additional links they will be ofcourse added.
You can customise the fields for storing such data if you wish:
collection({ id: "Comments", fields: [], relations: [ relation({ id: "post",
// To which collection are we linking? Specificy the id to: "Posts",
// By default it is required and it will infer this requiredness to the field if it's a direct relation isRequired: true,
// You can either use a string (of a field already added), omit it (it will generate a default one by suffixing the relation id with 'Id' or 'Ids') field: field({ id: "myCustomPostId", type: field.types.OBJECT_ID, }), }), ],});
One-to-One
If you want to benefit of a one-to-one relationship, use unique: true
inside the direct relationship definition. This will make the inversed side refer to the direct side as a single object instead of an array.
Rendering
So, relations have an additional field which we call representedBy
this basically means, if I have a Post
and I have a post owner which is a User
how do I show that user? Do I show his id, what fields from user do I show? And the typical answer is either a specific field that indifies it or a reducer. This helps the UI Lists to properly render data and know what to query.
relation({ id: "owner", to: "Owner", representedBy: "fullName",});
Shared Models & Enums
Shared models are very useful especially when you have the same type of data in multiple collections. A very common use case is Address
:
app({ sharedModels: [ sharedModel({ id: "Address", fields: [ // Normal field configuration as you're used to ], }), sharedModel({ id: "GenericStatus", enumValues: ["TO_DO", "IN_PROGRESS"], // same as enum }), ], collections: [ collection({ id: "Orders", fields: [ field({ id: "customerAddress", type: field.types.OBJECT, model: "Address", // you can also use a reference to an actual sharedModel or a function that returns it (useful for file splitting) }), field.enum("status", { model: "GenericStatus", // or () => GenericStatus if you separated it in another file }), ], }), ],});
So there are two steps, you define the shared models at application level, then you choose which field represents it. The model definition is stored separate and re-used by all, including validation and everything you would expect.
Uploads
If you want to benefit of uploads using S3 via XS3Bundle, then you need to add a relation to "AppFiles" like this:
relation({ id: "pictures", to: "AppFiles", isMany: false, // you can also have multiple uploads field: s.field({ id: `${id}Id`, type: s.field.types.OBJECT_ID, }), ui: { create: true, edit: true, list: true, listFilters: false, view: true, }, ...override,});
You can also have a file group, a file group is basically a separate entity which stores multiple files.
return s.relation({ id: "houseGallery", to: "AppFileGroups", isMany: false, field: s.field({ id: `${id}Id`, type: s.field.types.OBJECT_ID, }), ui: { create: true, edit: true, list: true, listFilters: false, view: true, }, ...override,});
This can be easily done with shortcuts, which is described right in the next chapter.
Shortcuts
You can easily create your own shortcuts by simply re-using fields, but we offer some sensible ones to aid you in your journey:
collection({ id: "Some Collection", fields: [ // This is the classic `_id` of ObjectId type shortcuts.field.id(), // isDeleted field works with deletable behavior shortcuts.field.softdeletable(), shortcuts.user.password(), // This stores the `IPasswordAuthenticationStrategy` from PasswordBundle
shortcuts.field.timezone("timezone"), // A simple field enum to easily benefit of timezone configurations
// This adds createdAt and updatedAt (note the ... and "fields") ...shortcuts.fields.timestampable(), // This adds createdById and updatedById ...shortcuts.fields.blameable(),
// This adds the necessary fields required for `IUser` from SecurityBundle ...shortcuts.fields.user.standard(), ],
relations: [ // This adds a link to user via "owner" and with shortcuts.relation.user({ id: "owner" }),
// Adds createdBy and updatedBy (works with Blameable behavior) ...shortcuts.relations.blameable()
// Files shorcuts.relation.file("picture"), shorcuts.relation.files("pictures"), shorcuts.relation.fileGroup("pictureGallery"), ],});
Feel free to get creative and create your own shortcuts for things you realise you use on multiple collections, but keep in mind to work with new instances of fields:
// BADconst shortcutField = field({ ... });
// GOODconst shortcutField = () => field({ ... })
Blendability
By "blendability" we mean that we can perform our own modifications to the code, while supporting subsequent blueprint generations that enhance and extend our app without removing our changes.
All files that get overriden on next generations are marked with the comment:
/** @overridable */
Exception to this rule is the .json
file for translations that is stored in {Collection}Management/config/{Collection}.i18n.json
We advise you not to make any changes directly to those files, their whole concept was designed in a way that they can be extended from your components.
Let's explore the types of customisations we can easily do:
Forms, Lists, Views
Our Form Schema resides in {Collection}Management/config/{Entity}CreateForm.base.tsx
. We do our modifications inside {Collection}Management/config/{Entity}CreateForm.tsx
.
build() { super.build();
// For example you want the description to become a TextArea instead of an input this.update("description", { render() { return <Ant.Form.Item><TextArea /></Ant.Form.Item> }, // Any other value as accepted by the Consumer
// You can also configure the order they are presented in order: 1, })}
This exact same concept is applied to all forms and all views.
If you want to request new fields from the API or remove certain fields, you have two options:
- Remove them from blueprint's ui
- Remove them from the consumer and from the requestBody
Edit forms, lists, views do requests. This request is done via static getRequestBody()
which is a Nova Request Body.
To customise this request to fit your needs:
class SampleForm { static getRequestBody(): QueryBodyType<ProjectsAsset> { // You have the ability to modify the request by adding certain fields or relations const requestBody = super.getRequestBody(); Object.assign(requestBody, { newField: 1, });
return requestBody; }}
Routes
Sometimes you might want to apply some security logic to your routes:
import { UserRoles } from "@root/api.types";
export const PROJECTS_ASSETS_CREATE = { ...BASE_PROJECTS_ASSETS_CREATE, roles: [UserRoles.ADMIN],};
Menus
You have the ability to configure the icon from @ant-design/icons easily via collection.ui.icon
, but if you want a custom icon to the menu:
If you want to customise the label either you do it via blueprint
, or by configuring the translation of it inside ${Collection}Management/i18n.ts
for management.projects_assets.menu.title
import { MyIconSvg } from "...";
export const PROJECTS_ASSETS_LIST = { ...BASE_PROJECTS_ASSETS_LIST, menu: { ...BASE_PROJECTS_ASSETS_CREATE.menu, icon: MyIconSvg, },};
If you want to add the menu as a submenu, we use the inject
functionality:
import { MyIconSvg } from "...";
export const PROJECTS_LABELS_LIST = { ...BASE_PROJECTS_LABELS_LIST, menu: { ...BASE_PROJECTS_LABELS_LIST.menu, inject: "PROJECTS_ASSETS_LIST", // This would appear as a submenu under the "Project Assets" },};
I18N
The translations inside {Collection}Management/config/{Collection}.i18n.json
will get overriden everytime. If you want to add your own translations the place to do so is inside ${Collection}Management/i18n.ts
import { i18n } from "@bundles/UIAppBundle/i18n";import phrases from "./config/ProjectsAssets.i18n.json";
phrases.management.projects_assets.menu.title = "My Custom Menu";
i18n.push(phrases);
// You can override additional messages here by using i18n.push();
Server Inputs & Models
For create
and edit
operations we call {Collection}InsertOne
and respectively {Collection}UpdateOne
mutations. The inputs used are {Entity}InsertInput
and {Entity}UpdateInput
.
These inputs are stored in api
under bundles/AppBundle/services/inputs
. As you already know, the .base
files get overriden but you can customise your validation logic and what not inside the regular ones:
import { Schema, Is, a, an } from "@bluelibs/validator-bundle";import { ContactUpdateInput as BaseContactUpdateInput } from "./ContactUpdate.input.base";
@Schema()export class ContactUpdateInput extends BaseContactUpdateInput { @Is(a.string().min(2)) firstName: string;}
By default Blueprint
doesn't allow such customisations from the get-go it only adds things such as nullability
and type, but it's easy to just go in the file and add them yourself.
The exact same concept applies to Collection models, you can extend them with ease, adding custom validations and maybe some custom methods that would ease development.
Live Data
If you want to view data in a live manner it's sufficient to change useData
with useLiveData
everything should work as you expect it to. Read more on the Live Data
section in the X-UI Core principles.
Generation Customisation
The generateProject
functions allows certain alterations:
generateProject({ // It will override, the non-overridable files, use it with caution and on a previously committed repo override: true, generators: [ // BACKEND MONGO COLLECTIONS GeneratorKind.BACKEND_COLLECTIONS,
// GRAPHQL CRUDS GeneratorKind.BACKEND_CRUDS,
// GRAPHQL Entities and inputs GeneratorKind.BACKEND_GRAPHQL,
// BACKEND MONGO COLLECTIONS FIXTURES GeneratorKind.BACKEND_FIXTURES,
// The UI Collections GeneratorKind.FRONTEND_COLLECTIONS,
// The actual pages with CRUD info in them GeneratorKind.FRONTEND_CRUDS,
// Authentication, development, etc, run only once when microservice is created GeneratorKind.FRONTEND_BOILERPLATE_COMPONENTS, ], writers: { // Writers receive models and a session, based on it they perform writes of the files // They are self-explanatory feel free to browse the code located in: // https://github.com/bluelibs/bluelibs/blob/main/packages/x/src/writers/ microservice: Writers.MicroserviceWriter, collection: Writers.CollectionWriter, graphQLCRUD: Writers.GraphQLCRUDWriter, genericModel: Writers.GenericModelWriter, graphQLEntity: Writers.GraphQLEntityWriter, graphQLInput: Writers.GraphQLInputWriter, collectionLink: Writers.CollectionLinkWriter, uiCollection: UICollectionWriter, uiCollectionCRUD: UICollectionCRUDWriter, fixture: Writers.FixtureWriter, },});
By offering this customisability, in theory, you can iteratively use less of the blueprint
features and also customise the way you write the files. If you are ambitious enough you could create such writers that write something completely different from X-Framework
or Foundation
like using Vue
instead of React
, that's how customisable and flexible this is. However, from our experience, it's not an easy task to run such generations while taking care of all the details.