Skip to content

15_Flox Modules

Joel Barmettler edited this page Feb 21, 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']); // 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.

Frontend

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. The Table is created dynamically based on a Columns object. The Interface definition can befound in the useDataTable file. In a minimal form, you must define an array of at least one object defining a table column with the appropriate name, label and field. The DataTable will generate itself with exactly one column, while automatically requesting data from the server and handling pagination, data editing and more.

Most of the column interface options are self explanatory: The name attribute is used as a key while the label is the locale dependent user-facing column header. The field is a string describing the attribute from the database that shall be displayed in the corresponding row. Most of the column definitions here correspond to the column props by Quasar. A simple example of a two-column table definition is shown here:

const columns: Ref<ColumnInterface<MyEntity>[]> = ref([
  {
    name: 'name',
    label: i18n.global.t('myentity.name'),
    field: 'name',
    align: ColumnAlign.left,
    sortable: true,
    edit: true,
  },
  {
    name: 'email',
    label: i18n.global.t('myentity.email'),
    field: 'email',
    align: ColumnAlign.left,
    sortable: true,
    edit: true,
    qInputProps: { rules: emailRules },
  },
]);

Sidenote: The emailRules in the context above are a simple set of functions that follow the ValidationRule type definition: A function taking an input object and returning either a boolean or the string describing the error. You can use handy helpers to convert Joi rules to ValidationRules:

const emailRules: ValidationRule[] = [
  joiSchemaToValidationRule(
    Joi.string().email({ tlds: { allow: false } }),
    i18n.global.t('validation.email')
  ),
];

Once the column definitions are in place, the DataTable can be used:

<DataTable
  :title="$t('myentity.table_title')"
  prepend-slot
  export-selection
  delete-selection
  multi
  :prepend-name="$t('myentity.avatar')"
  :columns="columns"
  :query="SEARCH_MY_ENTITIES"
  :update-mutation="UPDATE_MY_ENTITY"
  :delete-mutation="DELETE_MY_ENTITY"
>
  <template #prepend="slotProps">
    <q-avatar size="26px">
      <img :src="avatarForUser(slotProps.row.uuid)" alt="avatar" />
    </q-avatar>
  </template>
</DataTable>

There are several things to note here. First, note that the DataTable requires several query or mutation definitions as properties: The query prop takes the GraphQL SearchQuery as an input, while the updateMutation and deleteMutation props are the corresponding mutations. These are of type QueryObject and MutationObject, respectively. The mutations are optional: If you omit these queries, the DataTable will disable its inline editing functionality (a functionality that allows users to click at any datapoint within the table and instantly edit them) or not delete items (users can usually select one or more rows and delete them all at once). The DataTable also offers a lot of flags that change its optics and behaviours, so have a look at the defines properties.

The second thing to note is that we of course hand the column definitions as a prop.

Lastly, the example also makes use of a special slot the table offers, namely the prepend slot. If the prepend slot is toggled in the props, the DataTable will create an additional, virtual column at index 0. The content of this column is determined by the components you insert into the #prepend slot: The slot receives the row as an input, through which the item itself (in this example, the MyEntity) can be accessed. Here, we use the slot to display an avatar component with a cute avatar that is randomly generated based on the entities uuid. The DataTable also offers an #append slot with similar functionality but which is spawned at the very last index. Further, the DataTable offers an actions slot, which receives the selected item(s) as an input, allowing you to insert (for example) buttons that manipulate the selected items on-click.

Module Name

Source

How it works

When to use it

How to use it