Skip to content

Latest commit

 

History

History
268 lines (194 loc) · 13.7 KB

README.md

File metadata and controls

268 lines (194 loc) · 13.7 KB

Core

About

This library contains all types, and utilities needed to build applications, forms, and schemas that act as the main base for the whole application system.

Application

The application type describes an instance of a stored application. It includes information about:

  • who the applicant is
  • what is (s)he applying for (typeId)
  • the needed answers to the questions in the application
  • the externalData attached to the application
  • what state the application is in
  • and more

Data Providers

Many applications need to store external data that cannot be manipulated, but should be stored within the application. This data is often fetched from external sources (via x-road or other services available to island.is) and is used for either pre-filling fields in the form, or for validation and information uses.

Application Template

The ApplicationTemplate interface is the heart of the whole system. Each self-service application flow depends on having a template that extends this interface. Each application template has a unique type, a dataSchema for quick data validation, answerValidators for more customizable server side validation, and, most importantly, a stateMachineConfig to describe the overall flow for the application, and how users with different roles can interact with an application in its varying states.

Translations

In order to define "translatable" messages on the API side, you will need to define the translationNamespaces field on the template. It accepts an array of namespaces, in case you are using messages from multiple namespaces, coming from Contentful.

Once loading a namespace for the first time, it will the latest data from Contentful, and will cache the messages for 15 minutes.

Configuration

Add the following to your template object.

const ReferenceApplicationTemplate: ApplicationTemplate<
  ApplicationContext,
  ApplicationStateSchema<ReferenceTemplateEvent>,
  ReferenceTemplateEvent
 > = {
  type: ApplicationTypes.EXAMPLE,
  name: m.name,
+ translationNamespaces: [ApplicationConfigurations.ExampleForm.translation],
  dataSchema: ExampleSchema,

Example from here.

States

You can define a title and a description fields on each state of your state machine. These two fields will be used on the applications list on the service portal. It gives a better understanding for the user the current step of the process it is in.

[States.draft]: {
  meta: {
    name: 'Umsókn um ökunám',
+   title: m.draftTitle,
+   description: m.draftDescription,
    progress: 0.25,
    lifecycle: DefaultStateLifeCycle,
    roles: [

Screenshot 2021-05-17 at 08 56 51

{% hint style="info" %} At the moment, only the description field is used on application list. The title field is meant to be use in a later iteration of the application page's design. {% endhint %}

Header information

In order to show the information in the header (as shown bellow) regarding the institution handling the application and the application name, you need to pass an institution field to the template. It is accepting both a string and a "translatable" object.

Screenshot 2021-05-17 at 08 38 33

const ReferenceApplicationTemplate: ApplicationTemplate<
  ApplicationContext,
  ApplicationStateSchema<ReferenceTemplateEvent>,
  ReferenceTemplateEvent
 > = {
  type: ApplicationTypes.EXAMPLE,
  name: m.name,
+ institution: m.institutionName,
  translationNamespaces: [ApplicationConfigurations.ExampleForm.translation],
  dataSchema: ExampleSchema,

The application's name will be picked up from the name field from the same object above.

DataSchema

We are using zod to create the schema of the application. To pass a custom error message using a translation, we need to use the params field from the error message callback. You then can pass the "translatable" object from your message file.

.refine((n) => n && !kennitala.isValid(n), {
  params: m.dataSchemeNationalId,
}),

Example from here.

AnswerValidators

Same pattern as other fields, you can pass a string or a "translatable" object from your messages file.

Application Type

Each application template has its own unique application type. When a new application template is created, a respective enum value should be added to the ApplicationTypes enum.

Data schema

In order to have consistent form validation in the frontend and backend, each application template should be accompanied by a dataSchema. This dataSchema is implemented using Zod which is a powerful TypeScript-first schema declaration and validation library. The schema is an object, where the keys are the ids of all the questions that need validation for this given application template, and the value is a zod object describing what validation is needed for that given question and what error message to show if it fails.

Answer Validators

Sometimes, we need to provide our application templates with more complicated validation than a Zod dataSchema can offer. That is where answer validators come in. The answer validators are stored in a map, where the key is a path to the answer that needs to have this custom validation, and the value is a function which gets the current application and the new answer as parameters. The validators are only run on the server when the client tries to update an answer with a designated validator.

State Machine

Behind the scenes, application-core has types and interfaces for state machines which are extended from xstate. Each state in the application template state machine must include a meta object which describes the name of the state, what roles can access it, and what each role can do in said state.

States

Each application defines their own states. They can be as many as needed depending on how complicated or simple the application flow is.

A state is an abstract representation of a system (such as an application) at a specific point in time. As an application is interacted with, events cause it to change state. A finite state machine can be in only one of a finite number of states at any given time.

You can see a simple example from the meta-application here, or a more complex example from the Parental Leave here.

Status

To define the final state of your application, XState has a property called type: 'final'. It can be defined multiple times for your application states. For example, the Meta Application (link above) is either approved or rejected, and in both case the application is in its final state. A final state is final, and there are no events that lead out of it.

This type: 'final' is important because, out of it, we define a status column in the application model. This status is the same for every application template and give us the general progress of the application. We have, at the moment, 3 different status as follow, and let us filters and list applications by status type.

export enum ApplicationStatus {
  IN_PROGRESS = 'inprogress',
  COMPLETED = 'completed',
  REJECTED = 'rejected',
}

Life cycle

States can define their own life cycle:

type StateLifeCycle =
  | {
      // Controls visibility from my pages + /umsoknir/:type when in current state
      shouldBeListed: boolean
      shouldBePruned: false
    }
  | {
      shouldBeListed: boolean
      shouldBePruned: true
      // If set to a number prune date will equal current timestamp + whenToPrune (ms)
      whenToPrune: number | ((application: Application) => Date)
    }

By default states will not be pruned and will always be listed. The default options are defined in libs/application/core/src/lib/constants.ts.

Sample use case:

  • An application template needs to validate some logic before "creating" the actual application, in this case "created means":
    • visible to the user from my pages and application overview screen (/umsoknir/:type)
    • not automatically pruned after a few hours of inactivity
  • The initial state will then define the life cycle property as following:
    • shouldBeListed: false the application will not be listed in my pages or on /umsoknir/:type
    • shouldBePruned: true inactive applications will be automatically pruned to not waste space in the database if they are not moved into another state after a certain period of time
    • whenToPrune: 12 * 3600 * 1000 the application can be deleted after 12 hours of inactivity

These values are persisted into database for querying, so if an application has already been published and they are changed a database migration will also have to be included to update existing applications.

Roles

Each role can read or write different data stored in the application. Not only that, but each role has its own formLoader to describe what form should be rendered for said role in this specific state. For example, when an application is in review, the applicant should see a different form than the reviewer. Also, the applicant can no longer write any new answers, only read them, while a reviewer might be able to read everything and even write some new answers as well. This logic is also applied by the backend to make sure when a person queries for an application, the answers stored in the database for said application are trimmed so the person only gets to see the answers that (s)he is allowed to in that state.

In addition to information about which form to load, what data this role can read and write, the role includes a (possibly empty) list of actions. Each action maps to an event that is used by the state machine to transition into another state. In the example below, the applicant cannot perform any actions in the inReview state, while the reviewer has all the power to APPROVE or REJECT the application, resulting in a state transition.

stateMachineConfig: {
  states: {
    ...
    inReview: {
      meta: {
        name: 'In Review',
        roles: [
          {
            id: 'reviewer',
            formLoader: () =>
              import('../forms/ReviewApplication').then((val) =>
                Promise.resolve(val.ReviewApplication),
              ),
            actions: [
              { event: 'APPROVE', name: 'Samþykkja', type: 'primary' },
              { event: 'REJECT', name: 'Hafna', type: 'reject' },
            ],
            read: 'all',
            write: {
              answers: ['reviewerComment'],
            },
          },
          {
            id: 'applicant',
            formLoader: () =>
              import('../forms/PendingReview').then((val) =>
                Promise.resolve(val.PendingReview),
              ),
            read: 'all',
          },
        ],
      },
      on: {
        APPROVE: { target: 'approved' },
        REJECT: { target: 'rejected' },
      },
    },
    ...
  },
},

Form

The Form type describes how to structure the flow of a form. It is basically a big json object which is used by application-ui-shell to know what to render on the screen.

The structure of a form describes how questions and other fields are displayed, in what section or subsection they belong to, and in what order. It is basically a tree where the root is the Form, and the leaves are renderable Fields. In between there are nodes that describe the structure in more detail, such as Sections, SubSections, MultiFields, ExternalDataProviders and Repeaters.

Fields

A form field can be a question that the applicant needs to answer, or just something purely cosmetic or informational. This library provides prebuilt reusable fields (such as TextField, CheckboxField, RadioField and more), and also an interface for a custom field. In order to get data schema validation for a field, the id of the field needs to be present in the application template dataSchema object. It is even possible to provide a field with pure defaultValue if no answer has been provided by the user.

Conditions

Fields can have conditions to be shown/hidden under some given circumstances. These conditions can be dynamic (open-ended function), or static (depend on answers to other questions).

Sections and SubSections

These are only used for cosmetic reasons. They divide the form flow into meaningful chapters, which allow users to know how far into the form process they are.

Multi-fields

These are only used for cosmetic reasons. They group fields together so the application-form UI renders multiple fields on the screen, instead of the default one field per screen behavior.

External Data Providers

Many applications rely on external data that should not be editable by any user or consumer of an api. The externalData of an application is only updated by the backend via custom-made DataProviders.

Code owners and maintainers