HomeUniteUs icon indicating copy to clipboard operation
HomeUniteUs copied to clipboard

Generated intake profile fields

Open erikguntner opened this issue 10 months ago • 0 comments

This PR outlines a basic approach to rendering intake profiles based on objects defined in the database. This isn't a complete example, but I think it allows us to visualize further development of the data model to help fit our needs.

I accidentally created this branch off of the fix-mocking-errors branch, so I apologize for the added noise. If you would like to view only the relative changes use this link since it filters out the other files.

What changes did you make?

As I mentioned there is quite a bit of noise in this PR so I wanted to highlight some of the most relevant files and what they are doing.

Data layer:

  • app/src/services/profile.ts:
    • Contains the types and API definitions of the endpoints for retrieving profiles and answers.
  • app/src/utils/test/db/profile.ts:
    • Contains an array of the two intake profiles that can be rendered. You can see what the required format for the profiles might look like
  • app/src/utils/test/browser.ts:
    • This file defines the handlers to be mocked/intercepted in a browser environment. You can that this functionality is only enabled in a development environment and we can exclude routes that we don't want to check.
  • app/src/utils/test/handlers/profile.ts:
    • This file contains the mocked handlers for the endpoints used in app/src/services/profile.ts. You can see that the endpoint /api/profile/:profileId returns the profile app/src/utils/test/db/profile.ts based on the id passed in as an argument. The endpoint /api/profile/answers/:userId returns an empty object for now since I have to define and answers array, but the components have been tested with both an empty answers array and a partial answers array.
    • This is an example of how we can the Mock Service Worker library to mock out API requests in a browser environment during development when the endpoints are not readily available. These mocks can then be used for tests as well.

View layer:

  • app/src/views/IntakeProfile/index.tsx:
    • Gets the profileId and groupId from the URL. The profileId is used to fetch the corresponding profile and fields from the server. The groupId is used to determine which field group/section to render.
    • useGetProfileQuery makes a get request using the profileId to return the given profile
    • useGetAnswersQuery makes a get request using the userId to return all answers associated with a user. This is using a fixed id and returns an empty array for now.
    • buildValidationSchema uses the fieldGroups from the returned profile and the groupId to build a validation schema for the group. More on the details of this below.
    • createInitialValues uses the return fieldGroups and answers to create an object used as the initial values for Formik. More details on this below.
    • Provides General layout for the profile view.
    • Creates a Formik context that handles the storage of all initial values and validation schemas. As well as the propagation of form values and errors to child components.
    • Iterates over the fieldGroups and renders a list of links on the sidebar that update the URL with the associated fieldGroup id. This determines which fieldGroup's fields to render.
    • An Outlet component is used which is a placeholder component provided by react-router that is replaced with the FieldGroupList component. More on that below. This Outlet also provides a context, which we use to pass values down to the FieldGroupList.
    • onSubmit merges the updated answers with the existing answers objects, and if there are no validation errors, will create an alert with the answers to be submitted.
  • app/src/views/IntakeProfile/constants/index.ts
    • fieldGroupBuilder and fieldBuilder are helper functions that generate random field groups and fields.
    • createInitialValues creates an object of initial values for Formik. The object is this shape:
      • {
           [fieldGroupId]: {
               [fieldId]: answer
               ....
           }
           ....
         }
        
      • You'll see that most of the field components have a name property of fieldGroupId.fieldId. This is how Formik handles nested data structures.
      • It also utilizes a function fieldDefaultValue. This returns a default value based on the field type if an answer doesn't exist.
    • buildValidationSchema creates a validation schema for the field group with a similar structure to createInitialValues which is fieldGroupId.fieldId = schema.
      • This utilizes another function createFieldValidationSchema which creates a schema at the field level that takes the schema based on the field type and merges it with any further requirements. This is not fully fleshed out and needs more work, but shows how it would work for required fields and fields that have a constraint necessary for being required using required_if property.
    • I think most of the complexity of this approach lies in these two functions. However, I think the complexity is offset by some of the benefits listed below.
  • Created the IntakeProfileGroups components which render all the fields in a field group using a switch statement to render the required field based on the field type. Many of these are basic field types that could be seen throughout the profile.
  • app/src/views/IntakeProfile/hooks/useFieldGroups.ts Generates field groups and answers but is replaced by the profiles service. Could probably be removed.
  • app/src/components/IntakeProfile/IntakeProfileGroups.tsx Contains two components:
    • FieldGroupList uses the fieldGroups and groupId to find the right fieldGroup and iterates over its field. Each field is passed to the RenderFields component which uses a switch statement to render the corresponding field based on the field type. I think there are opportunities for refactoring here to make things a bit cleaner.
  • views/constanst/intakeProfile contains sets of types describing the view model, a set of helper functions to build groups and fields, as well as validation schemas for each field type.
  • app/src/components/IntakeProfile/AdditionaGuestsField.tsx I wanted to see what it was like adding a more complex field to this implementation and found it wasn't too bad.

Rationale behind the changes?

I wanted to explore what was possible when using a data model that describes the intake profile to generate the necessary fields.

Pros:

  • The primary benefit of this approach is that we only need to write this once and it can be used for all profiles. All we have to do is fetch the predefined profile by its id and merge that information with the fetched answers.
  • Less development work overall
  • Is closer to our goal of having this work for any organization. If you can imagine this app being used by many different host home organizations. Instead of having to manually build out each intake profile, we just need to define the structure, build out any custom components and validations we don't already support and it's ready to go.

Cons:

  • Some added complexity and if the design changes drastically could be difficult to refactor.
  • This covers some of the more complicated cases, but not all. However, I'm confident we can find solutions with the groundwork laid out.
  • Will require good documentation in case there is turnover so new members can understand the system.

What did you learn or can share that is new?(optional)

  • Passing values to Outlet components: https://reactrouter.com/en/main/components/outlet
  • Handling nested Formik fields: https://formik.org/docs/guides/arrays

Resources

The data model is heavily inspired by Typeform's API with a few tweaks:

  • https://www.typeform.com/developers/responses/JSON-response-explanation/
  • https://www.typeform.com/developers/create/reference/create-form/

Data Model

erDiagram
    INTAKE_PROFILE ||--|{ FIELD_GROUP : contains
    INTAKE_PROFILE {
        string id PK
        string name
    }
    TYPE {
        string id PK
        string type "long_text, short_text, multiple_choice, yes_no, email, phone_number"
    }
    FIELD_GROUP ||--|{ FIELD : has
    FIELD_GROUP {
        string id PK
        string profile_id FK
        int order
        string title
    }
    FIELD ||--|| PROPERTIES : has
    FIELD ||--|| VALIDATIONS : has
    FIELD ||--|| TYPE : has
    FIELD ||--|| ANSWER : has
    FIELD {
        string id PK
        string field_group_id FK
        string type_id FK
        int order
        string title
    }
    PROPERTIES ||--|{ CHOICES : "can contain"
    PROPERTIES {
        string id PK
        string field_id FK
        string description "all"
        boolean randomize "multiple_choice, dropdown"
        boolean alphabetical_order "multiple_choice, dropdown"
        boolean allow_multiple_selection "multiple_choice"
        boolean allow_other_choice "multiple_choice"
    }
    VALIDATIONS {
        string id PK
        string field_id FK
        boolean is_required "all"
        int max_characters "short_text, long_text"
    }
    CHOICES {
        string id PK
        string properties_id FK
        string label
     }
     
    ANSWER {
	    string id PK
	    string field_id FK
            int user_id FK
	    jsonb value "unsure of the best way to define this type since it needs to cover a many different data types (e.g)"
    }

Something that I believe is worth considering is whether we need to store the INTAKE_PROFILE, FIELD_GROUP, FIELD along with the PROPERTIES, and VALIDATIONS in the database at all. These only describe the intake profile and are not created, updated, or deleted by users. An alternative might be to define the two intake profiles in a more readable file format (like yaml) that could be read and parsed on both ends for their necessary purposes. These files could then act as a source of truth for the shape of the intake profiles and we wouldn't have to go through updating the database if you wanted to change the structure of the profile. Also, since the data model is still very much in flux it would make it easier to make updates to it. I think this would cut down on the complexity of the backend data model and allow to store just the information related to the users.

Example Field Group

{
      id: '7767',
      title: 'Personal Information',
      fields: [
        {
          id: '3029',
          title: 'What is your email?',
          type: 'email',
          properties: {},
          validations: {},
        },
        {
          id: '2584',
          title:
            'What is your work history?',
          type: 'long_text',
          properties: {},
          validations: {},
        },
        {
          id: '3271',
          title:
            'What is your phone number?.',
          type: 'number',
          properties: {},
          validations: {},
        },
      ],
    },
}

Screenshots of Proposed Changes Of The Website (if any, please do not screen shot code changes)

This video shows profiels 1 and 2 being rendered along with validation.

https://github.com/hackforla/HomeUniteUs/assets/27253583/6bb044fe-648b-4687-9802-ade3de1d4ea9

erikguntner avatar Apr 03 '24 00:04 erikguntner