Skip to content

15_Flox Modules

Joel Barmettler edited this page Jan 31, 2023 · 6 revisions

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.

Abstract CRUD Module

Source

How it works

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.

When to use it

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).

How to use it

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.

Abstract Search Module & DataTable

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.

How it works

The explenations are splitted into front- and backend, as they both bring their own challenges.

Backend (Abstract Search Module)

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.

Frontend (DataTable)

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.

MicrosoftTeams-image

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.

When to use it

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.

How to use it

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.

Backend

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']);
  }

  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.

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)

Module Name

Source

How it works

When to use it

How to use it