-
Notifications
You must be signed in to change notification settings - Fork 1
15_Flox Modules
Flox comes equipped with a lot of default modules. This page documents the use-case and code structure / architectures of the most fundamental ones. Please update this list whenever new std. modules are developed / transfered to flox.
Developing new modules in Nest can sometimes be repetitive. Some service functions and graphql resolvers are almost always the same. Consider a delete resolver, for example. It almost always call the delete method from the corresponding service with an UUID as an argument. The service then uses this UUID to find and delete the entity using the corresponding repository.
The goal of the abstract CRUD module is to offer basic CRUD (Create, Read, Update, Delete) out-of-the-box for any module that inherits and implements from this abstract module.
Both the AbstractCrudService and the AbstractCrudResolver offer the following basic functionalities:
- getOne: Returns one entity based on given uuid
- getMultiple: Returns multiple entities based on given array of uuids
- getAll: Returns multiple entities based on pagination criteria
- create: Creates a new entity based on input
- delete: Removes an entity based on given uuid
The resolver simply forwards the given input to the service function and is hence trivial. Note that the abstract CRUD module also defines a set of abstract dto and input objects that are expected by their corresponding service / resolver counterparts.
You can (and probably should) use the abstract CRUD module when you are about to build a new module (either for a project or as a flox core module) that needs CRUD abilities, but does not need to be access rights controlled (more on that topic later).
To leverage the abstract CRUD module, create a new module and make sure that your new Service inherits the AbstractCrudService and your Resolver inherits the AbstractCrudResolver.
To correctly inherit these two classes in Typescript, you must specify the generic types while inheriting. Hence, your class declaration will look something like this:
// Service
@Injectable()
export default class MyService extends AbstractCrudService<MyEntity> { ... }
// Resolver
@Resolver(() => MyEntity)
export default class MyResolver extends AbstractCrudResolver<
MyEntity,
MyService
> { ... }
To make your Service & Resolver work, you must implement two abstract getters, one in each file. To make your Service work, implement the repository getter and return the repository specific to your module here. In the Resolver, implement the service getter and return your module specific service here.
If you have special needs for the dto/input object, create new ones but make sure to inherit from the abstract CRUD dot/input objects. The abstract create.input.ts, for example, is completely empty as the abstract CRUD module does not know anything a-priori about the possible entries of your entity. Hence, you will most likely create your own create.input.ts that inherits from the abstract one.
At this point, you should be fully setup with an empty Resolver and an empty Service. As both your Resolver and Service inherit from the abstract one, they implicitly already include the aforementioned methods: getOne, getMultiple, getall, create and update.
However: The abstract resolver does not annote the methods as @Query
or @Mutation
. Hence, even though the getOne
method is present, it is not callable through the graphql endpoint. Do make "expose" the functions, you need to re-declare it and add the appropriate graphql annotations. In the same step, you could also do checks to see whether the user actually has appropriate rights to edit the entity.
// my.resolver.ts
@Resolver(() => MyEntity)
export default class MyResolver extends AbstractCrudResolver<
MyEntity,
MyService
> {
get service(): MyService {
return this.myService();
}
@AdminOnly()
@Query(() => MyEntity, { 'name': MyEntity })
async getOne(@Args() getOneArg: GetOneArg, @CurrentUser() user: User): Promise<MyEntity> {
if (!hasRightsToAccess(user, getOneArgs.uuid) { ... } // Do additional access management here
return super.getOne(getOneArg); // Call the base method, which in turn will access the service
}
}
If you need to change the behaviour of a service function, you can simply redeclare it, call the super method and add additional behaviour.
// my.service.ts
@Injectable()
export default class MyService extends AbstractCrudService<MyEntity> {
get repository(): Repository<MyEntity> {
return this.myRepository;
}
async create(myCreateInput: MyCreateInput): Promise<MyEntity> {
// Do custom functionality
const createdEntity: MyEntity = await super.create(myCreateInput);
return createdEntity;
}
}
The same holds true for the Resolver: You can of course change the resolvers and add additional functionality, or you could fully override the resolver functions and call the service yourself, of course.
Source Backend Source Frontend
The abstract Search module offers search functionality out-of-the-box for all modules that implement it. Through the provided search function, users can search for entities that include a given string (the so called filter string) and sort the results by some table row in ascending or descending order.
Note that the abstract Search module inherits from the abstract CRUD module. Hence, implementing the Search service automatically also implements the Abstract CRUD Module.
The true power of this module is activated when it is combined with the DataTable. The DataTable is a frontend component that accepts GraphQL query & mutation definitions (Search, Update, Delete) that are all automatically provided by the AbstractSearch Module. Hence, all backend modules that inherit from the AbstractSearch Module are inherently compatible with the DataTable. When the table is used in the frontend, it automatically loads N datapoints and displays them neatly. The user can then interact with the table: He can search for something using the search box, sort the table by new attributes and paginate freely through the available entities. The table hereby automatically makes the appropriate GraphQL requests to the resolver endpoints defined by the AbstractSearch module.
The explenations are splitted into front- and backend, as they both bring their own challenges.
The backend offers new abstract classes for the Service and Resolver, respectively. Both inherit from their abstract CRUD counterparts and extend 1 method: search.
The abstract search module also offers a new dto arg that defines the Search Args. Note that this args object itself inherits from the get-all.args.ts from the abstract crud module: Hence, the search args support pagination parameters.
The abstract search module also offers a so called Search Output that defines two fields: cound and data. When the search resolver endpoint is called, it returns an object that satisfies the search interface output structure. The 'count' indicates how many items fit the search query independent of the give 'skip' and 'take' parameters. The 'data' key will then hold an array of actual data items that fit the users search query.
The Resolver is then quite trivial: It accepts a searchQueryArgs input and returns a SearchQueryOutputInterface. Note that one question arrises: How can we specify on which entity fields the search is executed? This is specified in the constructor: a constructor argument called searchKey must be specified in the super-call from the inherited class. It contains a list of strings defining the table row names on which the search shall be performed. Hence, in the case of a user, the search keys could be ['username', 'email'] such that a search for 'po' will only return users that have 'po' contained in either their name or email, not in their uuid or cognitoID though.
The Service contains the logic to actually perform the search. It creates a nested where-object for each of the given searchKeys and counts the available data. The returned data structure fullfills the SearchQueryInterface.
The DataTable is a large Vue component where most of the logic is outsourced into the useDataTable composable.
The DataTable fetches its content automatically using the given Queries and hence handles the logic of lazy pagination, searches and row sortings. It can also be used to inline-edit content by clicking on a table cell and writing a new value in there. It will use the given Update-Mutation to perform a GraphQL Query to update the edited object. Further, table rows (entities) can be selected and deleted by the user, for which the DataTable uses the given Delete-Mutation.
The Table itself is highly customizable. It offers a lot of props:
Prop | Default | Description |
---|---|---|
query | required | GraphQL QueryObject that satisfies the 'search' args from the AbstractSearch-Module |
updateMutation | undefined | GraphQl MutationObject that satisfies the 'update' args from the AbstractCrud-Module |
deleteMutation | undefined | GraphQl MutationObject that satisfies the 'delete' args from the AbstractCrud-Module |
columns | required | Defines what columns are displayed and in what style, according to a Column Interface |
tableProps | {} | Properties for the underlaying QTable |
exportSelection | false | Enables the ability to export selected entities as a CSV document |
deleteSelection | false | Shows a 'Delete' button when rows are selected, deletes these rows on click |
hideFullscreen | false | Hides the 'fullscreen' button that switches the table into fullscreen mode |
hideSearch | false | Hides the tables search input field |
hideColumnSelector | false | Hides the dropdown menu that lets the user choose which table columns to display |
prependSlot | false | Creates a new virtual table column at the first position that can be filled using a slot |
prependName | '' | Row title that is displayed above the virtual first column |
appendSlot | false | Creates a new virtual table column at the last position that can be filled using a slot |
appendName | '' | Row title that is displayed above the virtual last column |
multi | false | Enables the selection of multiple table entities at once by either using the CTRL-Key to select arbitrary rows, or select a range by holding the SHIFT-Key |
removeIcon | 'delete' | Materialdesin Icon name used for the remove button |
removeLabel | 'Remove' | Label on remove button |
The DataTable offers two slots: the prepend and append slots can be used to insert content into the first resp. last virtual row of the DataTable. This is useful to inject buttons into the table, for examle, that route to a details page. Both slots receive a cellProp as input. To learn more about the details what values the cellProp object includes, navigate to the Quasar Table API and search for 'body-cell' under the 'Slots'-Tab.
Further, the DataTable offers an Actions Slot that can be used to display additional buttons. It receives an array of the selected Rows (Entity[]) as a property.
Lastly, the DataTable emits an update:selected event containing the list of currently selected entities.
The DataTable is an incredibly flexible component to display backend data as tables in the frontend. Hence, you may implement the AbstractSearch Module whenever you want to use the DataTable, and you may want to use the DataTable whenever you want to show entities in tabular format in the frontend.
You may also just implement the AbstractSearch Module without using the DataTable when you want to have CRUD and can make use of the search endpoints.
To understand how to use abstract modules in general, please read the section about the Abstract CRUD module first. The Abstract Search module inherits almost all principles and concepts.
If you are building a module and want to make it compatible with the DataTable, let both your Service and Resolver inherit from the respective Abstract Search Resolver and Service. Note that you do not need to inherit the Abstract CRUD Service / Resolver. In Fact, Typescript does not support multiple inheritance. That's also the reason why the Abstract Search itself inherently inherits from the Abstract CRUD. In Python, you would have implemented both as their own abstract classes and let your module inherit from either one or both - depending on your usecase. However, the case in which you will not need CRUD but only Search is weaker than the case in which you need both.
Your Resolver must inherit the AbstractSearchResolver:
// my.resolver.ts
@Resolver(() => MyEntity)
export default class MyResolver extends AbstractSearchResolver<
MyEntity,
MyService
> {
constructor(private readonly myService: MyService) {
super(['username', 'email', 'role']); // You decide what table rows to search here!
}
get service(): MyService {
return this.myService;
}
@AdminOnly()
@Query(() => MySearchOutput, { name: 'SearchMyEntity' })
searchMyEntity(@Args() queryArgs: SearchArgs): Promise<MySearchOutput> {
return super.search(queryArgs);
}
}
Let your Service inherit and implement the AbstractSearchService as follows:
// my.service.ts
@Injectable()
export default class MyService extends AbstractSearchService<MyEntity> {
constructor(
@InjectRepository(MyEntity)
private readonly myRepository: Repository<MyEntity>,
) {
super();
}
get repository(): Repository<MyEntity> {
return this.myRepository;
}
}
Note that you again need to implement the repository getter and explicitly annotate the functions in the resolver - exactly the same process steps as for implementing the abstract CRUD module.
We reference a MySearchOutput in the resolver. This is an output file you must define for each of your search output objects (remember, the object with data and count). This begs the question: Why can't we make this file generic? Unfortunately, we need to indicate the return object explicitly in the GraphQl @Query()
annotation and it does not allow generic objects nor interfaces. Hence, we must implement our own outputs/my.output.ts
for every module that implements the AbstractSearch Module.
The structure of the output file is trivial though and can almost always be copy-pasted with only slight manipulations:
import { Field, ObjectType } from '@nestjs/graphql';
import SearchQueryOutputInterface from '../../abstracts/search/outputs/search-interface.output';
import MyEntity from '../entities/my.entity';
@ObjectType()
export default class MySearchOutput
implements SearchQueryOutputInterface<MyEntity>
{
@Field(() => String, {
description: 'How many items are found within database',
})
count: number;
@Field(() => [MyEntity], { description: 'MyEntities that fit query' })
data: MyEntity[];
}
As a reference, you can refer to the UserService, UserResolver and UserOutput, as the auth user module implements the AbstractSearch Module.
The frontend must of course define an appropriate query to search users. The only notable thing about this query is that it returns two fields, count
and data
, hence you need to make sure to add your object specific attributes (uuid, name, email, ...) as a substructure of the data
.
export const SEARCH_MY_ENTITY: QueryObject = {
query: gql`
query SearchMyEntity(
$take: Int
$skip: Int
$filter: String
$sortBy: String
$descending: Boolean
) {
SearchMyEntity(
take: $take
skip: $skip
filter: $filter
sortBy: $sortBy
descending: $descending
) {
count
data {
uuid
name
}
__typename
}
}
`,
tables: [TABLES.MY_ENTITY],
cacheLocation: 'SearchMyEntity',
};
If you strictly follow the separation-of-concerns, you will also need to define an appropriate function in the corresponding service. Note that the returned data is of type CountQuery<MyEntity>
.
export async function searchMyEntity(
take: number,
skip: number,
filter: string,
sortBy: string,
descending = false
): Promise<CountQuery<MyEntity>> {
const { data } = await executeQuery<CountQuery<MyEntity>>(SEARCH_MY_ENTITIES, {
take,
skip,
filter,
sortBy,
descending,
});
return data;
}
Now that the basis are laid, we can use these queries for initiating the DataTable.