We're working with community non-profits who have a Host Home or empty bedrooms initiative to develop a workflow management tool to make the process scalable (across all providers), reduce institutional bias, and effectively capture data.
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:
{
....
}
....
}
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)
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.
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
:app/src/utils/test/db/profile.ts
:app/src/utils/test/browser.ts
:app/src/utils/test/handlers/profile.ts
:app/src/services/profile.ts
. You can see that the endpoint/api/profile/:profileId
returns the profileapp/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.View layer:
app/src/views/IntakeProfile/index.tsx
:profileId
andgroupId
from the URL. TheprofileId
is used to fetch the corresponding profile and fields from the server. ThegroupId
is used to determine which field group/section to render.useGetProfileQuery
makes a get request using theprofileId
to return the given profileuseGetAnswersQuery
makes a get request using theuserId
to return all answers associated with a user. This is using a fixed id and returns an empty array for now.buildValidationSchema
uses thefieldGroups
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 returnfieldGroups
andanswers
to create an object used as the initial values for Formik. More details on this below.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.Outlet
component is used which is a placeholder component provided byreact-router
that is replaced with theFieldGroupList
component. More on that below. ThisOutlet
also provides a context, which we use to pass values down to theFieldGroupList
.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
andfieldBuilder
are helper functions that generate random field groups and fields.createInitialValues
creates an object of initial values forFormik
. The object is this shape:fieldGroupId.fieldId
. This is how Formik handles nested data structures.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 tocreateInitialValues
which isfieldGroupId.fieldId = schema
.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 usingrequired_if
property.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 thefieldGroups
andgroupId
to find the rightfieldGroup
and iterates over its field. Each field is passed to theRenderFields
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:
Cons:
What did you learn or can share that is new?(optional)
Outlet
components: https://reactrouter.com/en/main/components/outletFormik
fields: https://formik.org/docs/guides/arraysResources
The data model is heavily inspired by Typeform's API with a few tweaks:
Data Model
Something that I believe is worth considering is whether we need to store the
INTAKE_PROFILE
,FIELD_GROUP
,FIELD
along with thePROPERTIES
, andVALIDATIONS
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
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