This is the fontend repo containing source code for the webapplication KSG-nett. This is a React + Typescript application uses vite as a bundler
Library | Function | website |
---|---|---|
Apollo client | Graphql queries and caching | Apollo docs |
Mantine | Component library | Mantine docs |
React hot toast | Toast component provider | Website |
React hook form | Form handling | Website |
Yup | Form validation | github |
In order for this app to function the backend part of the application needs to be running in the background. Follow the instructions in this repo.
Dependencies are managed with yarn
. To run the code do the following
- install yarn on your computer
- Clone and Navigate to this folder
- Install the dependencies by running
yarn install
- Run the projects by running
yarn start
Usually tasks are assigned through Shortcut stories, with a story type which is either a feature, bug or chore, and a story id. This is used to track progress on the task throughout development. A given branch will automatically get tracked if the branch includes sc-<story-id>
anywhere in its name.
- Our branches follows a convention of
story-type/sc-<story-id>/branch-name
So a story which is a bug type, with an id of 666 and a title of "Dashboard renders wrong data" would be named bug/sc-666/dashboard-renders-wrong-data
or similar.
In order to keep this repository maintanable please strive to follow the following code-style guidelines.
In most cases when creating a new component you would want to declare it in the following way
interface NewComponentProps {...}
const NewComponent = React.FC<NewComponentProps> = ({...}) => {}
When creating a component which is imported outside its module/folder use ordinary exports (not default) and export it explicitly in the module/folder index file. If we have src/modules/ProfilePage.tsx
and want to export it so we can import it for the router in src/container/AuthRoutes.tsx
it is exported as follows.
// ProfilePage.tsx
export const ProfilePage = () => {...}
//index.ts
export * from './ProfilePage`
When creating a new module, try to keep the structure as follows
src/modules
├── moduleName
│ ├── components/
│ │ ├── Component1.tsx
│ │ ├── Component2.tsx
│ │ ├── View2/
│ │ │ ├── View2Component1.tsx
│ │ │ ├── View2Component2.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── views/
│ │ ├── View1.tsx
│ │ ├── View2.tsx
│ │ └── index.ts
│ ├── enums.ts
│ ├── types.ts
│ ├── types.graphql.ts
│ ├── utils.ts
│ ├── queries.ts
│ ├── mutations.ts
│ └── mutations.hooks.ts
└── moduleName2/
Where the index files uses barrel export. Components can be isolated to their own folder if the components structure in a specific view becomes fairly large and is only scoped to that module.
All queries and mutations are to have their variables and returndata typed correctly where applicable. Types are to be declared in the module types.ts
file where the query or mutation is defined in queries.ts
or mutations.ts
respectively.
Queries are defined the gql
template tags in local queries.ts
files. A query for a user with a specific id would look like
// queries.ts
import { gql } from ´@apollo/client`
const USER_QUERY = gql`
query UserQuery($id: ID!) {
User(id: $id) {
id
fullName
balance
...
}
}
`
//types.graphql.ts
type UserNode = {
id: string
fullName: string
...
}
interface UserQueryVariables {
id: string
}
interface UserQueryReturns {
user: UserNode | null
}
Our backend schema works such that it returns null
if something goes wrong. An example of this being giving a id
which does not belong to any given user, so the lookup fails and the object resolves to null
. Given all this a UserProfile
view query would look something like this
import { useQuery } from '@apollo/client'
import { USER_QUERY } from './queries'
import { UserQueryVariables, UserQueryReturns } from './types'
const UserProfile: React.FC = () => {
const { data, loading, error } = useQuery<
UserQueryReturns,
UserQueryVariables
>(USER_QUERY,
{ variables: id: userId }
)
if (error) return (<SomeErrorComponent />)
if (loading | !data) return (<SomeLoadingComponent />)
const { user } = data
if (user === null) return (<SomeResourceNotFoundComponent />)
return <h1>Hello {user.fullName}. Welcome!</h1>
}
The reason we add !data
to the query loading check is that data is defined by apollo as T | undefined
where T
is the return type we defined (this case UserQueryReturns
). The Typescript compiler does not perform a deep enough check to understand that if error
and loading
are null
and false
then data
has to be defined, so we do this do make the TS compiler happy.
Mutations are effectively identical to queries in how we approach typing. The most notable thing to keep in mind is that most of the mutations available from the backend are automatically generated based of django models. Each model in most cases have a create
, patch
and delete
mutation available. Both create
and patch
accept a <MutationVerb><ModelName>Input
object. Given the User
we would have the following types.
// mutations.ts
const PATCH_USER = gql`
mutation PatchUser($id: ID!, $input: PatchUserInput!) {
patchUser(id: $id, input: $input) {
user: {
id
firstName
lastName
}
}
}
`
// types.ts
type CreateUserInput = {
firstName: string
lastName: string
}
type PatchUserInput = {
firstname: string
lastname: string
}
interface CreateUserMutationVariables {
input: CreateUserInput
}
interface PatchUserMutationVariables {
id: string
input: PatchUserInput
}
The greatest difference between useMutation
and useQeury
is the returnvalue of the hook. useMutation
returns an array where the first item is the function to execute the mutation and the second is different state variables similar to what useQuery
returns.
// PatchUser.tsx
const PatchUser: React.FC = () => {
const [patchUser, {data, loading} = useMutation<
PatchUserMutationReturns,
PatchUserMutationVariables
>(PATCH_USER, {
onCompleted(data){
const { user } = data
// Show a toast or something
}
}}
}
const handlePatchUser = () => {
createUser({
id: userId,
input: {
firstName: "Tormod",
lastName: "Haugland"
}
})
}
return (
<button onClick={handlePatchUser}>Create user</button>
)
This is a very simple mutation, in most cases we want do this in tandem with form handling which is covered in the next section (someday). Another thing to note is that triggering mutations also requries us to update the local state of data. This is done with the refetchQueries
option in useMutation
.
In order to make mutation use flexible we wrap them into hooks.
export function useUserMutations() {
const [createUser, {loading: createUserLoading}] = useMutation<
CreateUserMutationReturns,
CreateUserMutationVariables
>(CREATE_USER)
const [patchUser, {loading: patchUserLoading}] = useMutation<
PatchUserMutationReturns,
PatchUserMutationVariables
>(PATCH_USER)
const [deleteUser, {loading: deleteUserLoading}] = useMutation<
DeleteUserMutationReturns,
DeleteUserMutationVariables
>(DELETE_USER)
return {
createUser,
createUserLoading,
patchUser,
patchUserLoading,
deleteUser
deleteUserLoading
}
}
This way we can simply use them like this
const { createUser, createUserLoading } = useUserMutations()
For most forms we try to make use of a 3-layered form design where we delegate different tasks to different layers. We have a visual form layer, a logic layer dealing with form state and validation and an API/data layer dealing with the mutation of data and fetching.
We use react-hook-form
for form handling with yup
as a schema validator.
A form component then has 3 files
- MyForm.tsx
- useMyFormLogic.ts
- useMyFormAPI.ts
- index.ts
index only exports the Form component giving us a simple import/export API
// MyForm.tsx
import { useMyFormLogic } from './useMyFormLogic'
import { useMyFormAPI } from './useMyFormApi'
export const MyForm: React.FC = () => {
const { formState, formErrors, handleFormChange, handleFormSubmit } =
useMyFormLogic(useMyFormAPI())
return (
<form onSubmit={handleFormSubmit}>
<input
type="text"
name="firstName"
value={formState.firstName}
onChange={handleFormChange}
/>
<input
type="text"
name="lastName"
value={formState.lastName}
onChange={handleFormChange}
/>
<button type="submit">Create user</button>
</form>
)
}
The inspiration for this design is taken from this article.