Frontend - liferesearchapp/life-research-members-portal GitHub Wiki
This document relates to all files within the src
directory, excluding those covered in Backend and Authentication. Please see those sections before reading this one, as some of the concepts discussed are used here.
The Ant Design UI library was used for some pre-made components.
This section relates to the files within src/services
.
These files contain functions for communicating with the backend. These functions perform HTTP requests, add the correct headers, handle errors, and create notifications. Some functions are wrapped in a React Hook for single fetches, like fetching profile data. Some functions are wrapped in a React Context to make data accessible to all components, like the list of all keywords.
The src/services/notifications/notifications.ts
file contains a thin wrapper for Ant Design Messages. These are used to notify the user of loading, success, and errors.
Here's an example of the function to register an account:
src/services/register-account.ts
import type {
RegisterAccountParams,
RegisterAccountRes,
} from "../pages/api/register-account";
import ApiRoutes from "../routing/api-routes";
import { en } from "./context/language-ctx";
import getAuthHeader from "./headers/auth-header";
import { contentTypeJsonHeader } from "./headers/content-type-headers";
import Notification from "./notifications/notification";
export default async function registerAccount(
params: RegisterAccountParams
): Promise<RegisterAccountRes | null> {
const authHeader = await getAuthHeader();
if (!authHeader) return null;
const notification = new Notification();
try {
notification.loading(
en ? "Registering Account..." : "Compte d'enregistrement..."
);
const res = await fetch(ApiRoutes.registerAccount, {
method: "PUT",
headers: { ...authHeader, ...contentTypeJsonHeader },
body: JSON.stringify(params),
});
if (!res.ok) throw await res.text();
notification.success();
return await res.json();
} catch (e: any) {
notification.error(e);
return null;
}
}
This function does the following:
- Defines the correct parameter type and return type (imported from the backend file).
- Gets the authorization header containing the access token
- Displays a "registering account" notification
- Makes the HTTP request with the correct method, headers, and stringified body.
- Checks the return status of the request (
res.ok
means status is 200) - If successful: displays a success notification and returns the result
- If not successful: displays an error notification and returns
null
(notification.error
also logs the error to the console)
Here's an example of the hook to retrieve an account profile:
import { useEffect, useState } from "react";
import ApiRoutes from "../routing/api-routes";
import getAuthHeader from "./headers/auth-header";
import type { AccountInfo } from "./_types";
export default function useAccount(id: number) {
const [account, setAccount] = useState<AccountInfo | null>(null);
const [loading, setLoading] = useState(true);
async function fetchAccount(id: number) {
try {
const authHeader = await getAuthHeader();
if (!authHeader) return;
setLoading(true);
const result = await fetch(ApiRoutes.account(id), {
headers: authHeader,
});
if (!result.ok) return console.error(await result.text());
const account = await result.json();
setAccount(account);
} catch (e: any) {
console.error(e);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchAccount(id);
}, [id]);
function refresh() {
fetchAccount(id);
}
return { account, setAccount, loading, refresh };
}
The hook wraps a simple service function, but provides a state variable for React components to use, which will re-render the UI on change.
The hook also provides a mechanism to let components know if the data is loading.
It also provides a mechanism to overwrite the account in local memory, in the case we have updated the account using another service function, which returned the new value.
And finally it provides a function to re-fetch the account data.
The syntax for using the hook is just:
const { account, setAccount, loading, refresh } = useAccount(id);
You can see this hook being used in src/components/accounts/account-profile.tsx
React Contexts provide a way to easily cache data and share it between components. This is useful for immediately fetching tables that are not expected to change, such as the list of all faculties, and the list of all member types.
As this application was designed with a small database in mind, the list of all members, the list of all keywords, and the list of all accounts (if the user is an admin) are also fetched and cached on application load, to make the experience faster for the user.
A context is also responsible for managing the current user, as discussed in Authentication.
There is also a context that manages the user's preferred language, and stores the value in local storage to ensure it remains the same across sessions. src/services/context/language-ctx.tsx
Finally there is a context that manages if the user has unsaved changes, and prevents navigation with a save changes prompt. src/services/context/save-changes-ctx.tsx
Here's the context that fetches the list of faculties src/services/context/faculties-ctx.tsx
:
First we create the simple service function, no need for loading or success notifications since this will be happening in the background:
async function fetchAllFaculties(): Promise<faculty[]> {
try {
const res = await fetch(ApiRoutes.allFaculties);
if (!res.ok) throw await res.text();
return await res.json();
} catch (e: any) {
new Notification().error(e);
return [];
}
}
Then we create a context that defines what data and functions are exposed:
export const FacultiesCtx = createContext<{
faculties: faculty[];
refresh: () => void;
}>(null as any);
You can provide a default value instad of null
, but there's not much point as the default value should never be accessible.
Then we create a provider, which is a React component that will manage the context and provide it to all of it's children.
export const FacultiesCtxProvider: FC<PropsWithChildren> = ({ children }) => {
const [faculties, setFaculties] = useState<faculty[]>([]);
const { en } = useContext(LanguageCtx);
async function getFaculties() {
setFaculties((await fetchAllFaculties()).sort(en ? enSorter : frSorter));
}
useEffect(() => {
getFaculties();
}, []);
useEffect(() => {
setFaculties((prev) => [...prev].sort(en ? enSorter : frSorter));
}, [en]);
function refresh() {
getFaculties();
}
return <FacultiesCtx.Provider value={{ faculties, refresh }}>{children}</FacultiesCtx.Provider>;
};
To integrate the context into the application, we simply wrap the entire app in this component. But since there are quite a few contexts, we bundle them into a single component in src/services/context/_ctx-bundler.tsx
const AllContextProviders: FC<PropsWithChildren> = [
ActiveAccountCtxProvider,
LanguageCtxProvider,
MemberTypesCtxProvider,
FacultiesCtxProvider,
SaveChangesCtxProvider,
AllKeywordsCtxProvider,
AllAccountsCtxProvider,
AllMembersCtxProvider,
].reduceRight((Accumulator, Parent) => {
const Provider: FC<PropsWithChildren> = ({ children }) => (
<Parent>
<Accumulator>{children}</Accumulator>
</Parent>
);
return Provider;
});
export default AllContextProviders;
Now we just need to wrap the entire app with AllContextProviders
, which is done in src/pages/_app.tsx
, the file that renders Next.js pages.
<AllContextProviders>
<Navbar />
<div className="next-page-container">
<Component {...pageProps} />
</div>
</AllContextProviders>
Now any component can access a context with the following syntax:
const { faculties } = useContext(FacultiesCtx);
Next.js Pages are how Next.js handles routing.
Routes are generated based on the file names within src/pages
. For example, pages/register.tsx
generates a route at <domain root>/register
.
The file name index
will resolve to the path of the parent directory. For example, pages/members/index.tsx
generates a route at <domain root>/members
You can also include parameters in routes with square brackets. For example pages/members/[id]/public
will generate a route that accepts any string in place of [id]
, such as <domain root>/members/123/public
.
Each of these files should have a React component as their default export. When a user travels to one of these routes, that component gets rendered.
Its best not to have too much logic in these files, and just use this directory to render a single component from src/components
. Routing paths can be subject to change, and you don't want to be moving around a lot of code when that happens.
For example, here's the file for a member's public profile:
src/pages/members/[id]/public.tsx
import { useRouter } from "next/router";
import type { NextPage } from "next/types";
import PublicMemberProfile from "../../../components/members/member-public-profile";
const PublicMemberPage: NextPage = () => {
const router = useRouter();
const { id } = router.query;
if (!(typeof id === "string")) return null;
return <PublicMemberProfile id={parseInt(id)} />;
};
export default PublicMemberPage;
All we're doing is parsing the route parameter and rendering another component, passing the id through.
To avoid hardcoded strings during navigation, routes for every page are stored in src/routing/page-routes.ts
.
Navigation can be performed using the Next.js Router
router.push(PageRoutes.accountProfile(id));
Although the backend is protected against unauthorized requests, we'd prefer if unauthorized requests didn't happen at all.
If a user tries to navigate to a route that is not meant for them to see, we should not load the page, and instead redirect the user or display a message.
This application handles that case with a PageAuthGuard
component.
There are 4 types of authorizations defined:
src/components/auth-guard/authorizations.ts
enum Authorizations {
admin = "admin", // admins only
registered = "registered", // any registered account
matchAccountId = "match account id", // must have matching account id to a route
matchMemberId = "match member id", // must have matching member id to a route
}
The component just takes what authorizations are required, and decides whether or not to render its children based on the current active user. If the user is not authorized it will display a message.
src/components/auth-guard/page-auth-guard.tsx
type Props = {
auths: Authorizations[],
id?: number,
loadingIcon?: ReactElement,
};
const PageAuthGuard: FC<PropsWithChildren<Props>> = ({
auths,
id,
loadingIcon,
children,
}) => {
const { localAccount, loading } = useContext(ActiveAccountCtx);
const { en } = useContext(LanguageCtx);
if (loading) return loadingIcon || <CenteredSpinner />;
const notAuthorized = (
<h1 style={{ textAlign: "center" }}>
{en
? "You are not authorized to view this page."
: "Vous n'êtes pas autorisé à afficher cette page."}
</h1>
);
const c = <>{children}</>;
if (!localAccount) return notAuthorized;
if (auths.includes(Authorizations.registered)) return c;
if (auths.includes(Authorizations.admin) && localAccount.is_admin) return c;
if (auths.includes(Authorizations.matchAccountId) && localAccount.id === id)
return c;
if (!localAccount.member) return notAuthorized;
if (
auths.includes(Authorizations.matchMemberId) &&
localAccount.member.id === id
)
return c;
return notAuthorized;
};
Here's an example of using it to stop an unauthorized user from attempting to load the accounts interface:
const AccountsPage: NextPage = () => {
return (
<PageAuthGuard
auths={[Authorizations.admin]}
loadingIcon={<Table loading></Table>}
>
<AllAccounts />
</PageAuthGuard>
);
};
React Components are what make up the user interface. All of the components in this application are Function Components, as opposed to Class Components.
These UI components are located in src/components
.
Here is a list of each component and it's description:
Name | File | Description |
---|---|---|
Welcome | welcome.tsx | Displays a greeting message depending on the type of user signed in |
RegisterAccount | register-account.tsx | Form for an admin to register a new account |
Footer | footer.tsx | Displays a footer with a copyright notice and a link to the privacy policy |
Name | File | Description |
---|---|---|
AccountProfile | account-profile.tsx | Allows admins to view and edit an account |
AllAccounts | all-accounts.tsx | Table of all accounts |
DeleteAccountButton | delete-account-button | Allows deleting an account with a confirmation prompt |
DeleteMemberButton | delete-member-button.tsx | Allows deleting member information attached to an account with a confirmation prompt |
GrantAdminButton | grant-admin-button.tsx | Allows granting admin status to an account with a confirmation prompt |
RegisterMemberButton | register-member-button.tsx | Allows registering an account as a member with a confirmation prompt |
RemoveAdminButton | remove-admin-button.tsx | Allows removing admin status from an account with a confirmation prompt |
UpdateEmailButton | update-email-button.tsx | Allows opening a modal to edit an account's login email |
UpdateNameButton | update-name-button.tsx | Allows opening a modal to edit an account's first and last name |
Name | File | Description |
---|---|---|
AllEvents | all-events.tsx | This is a component that displays a table of events, with filters to filter the events based on name, type, start date and end date. |
DeleteEventButton | delete-event-button.tsx | This is a modal button component that, when clicked, opens a modal to confirm the deletion of an event. |
PrivateEventProfile | event-private-profile.tsx | This component displays the profile of an event, with the possibility to view or edit it |
PublicEventDescription | event-public-description.tsx | Displays information about a public event |
PublicEventForm | event-public-form.tsx | This component allows the user to update the public information of an event and manage the related topics, members, partners, products, grants, and events |
EventPublicProfile | event-public-profile.tsx | This is a component that displays the public profile of an event. |
RegisterEvent | event-register.tsx | This component allows users to register a new event by providing the event's name in English and French, date range, event type, and a note. |
EventSelector | event-selector.tsx | This component is a form element that allows a user to search for existing events and select one or more of them. |
EventTag | event-tag.tsx | This component is a presentational component that displays an event in a tag format. |
These are Custom Ant Design Form Controls
Name | File | Description |
---|---|---|
FacultyFilter | faculty-filter.tsx | Allows selecting a set of faculty ids via a dropdown select |
KeywordFilter | keyword-filter.tsx | Allows selecting a set of keyword ids via a dropdown select |
MemberNameFilter | member-name-filter.tsx | Allows selecting a set of member ids via a dropdown select |
MemberTypeFilter | member-type-filter.tsx | Allows selecting a set of member type ids via a dropdown select |
These are helpers functions that return a list of member names
Name | File | Description |
---|---|---|
getInvestigatorMember | grant-investigator-member-getter.tsx | Returns a list of names of grant investigators |
getMemberInvolved | grant-member-involved-getter.tsx | Returns a list of names of members involved in a grant |
getMemberOrg | member-partner-getter.tsx | Returns a list of names involved in a partnership |
getMemberProduct | member-product-author-getter.tsx | Returns a list of products authored by members |
getMemberSupervision | member-supervision-getter.tsx | Returns a list of names of principal supervisors |
getMemberAuthor | product-member-author-getter.tsx | Returns a list of names of members who are authors of a product |
Name | File | Description |
---|---|---|
AllGrants | all-grants.tsx | displays a table of grants, with filters to filter the grants based on name, status and source |
DeleteGrantButton | delete-grant-button.tsx | A button that opens a modal to delete a grant |
PrivateGrantProfile | grant-private-profile.tsx | A detailed view of a private grant's profile |
PublicGrantDescription | grant-public-description.tsx | Displays the public information of a grant in a table format |
PublicGrantForm | grant-public-form.tsx | Displays a form for updating public information of a grant. |
PublicGrantProfile | grant-public-profile.tsx | Displays the public grant profile information for a specific grant id. |
RegisterGrant | grant-register.tsx | Provides a form for registering a grant |
GrantSelector | grant-selector.tsx | Allows a user to search for grants and select one or more of them |
GrantTag | grant-tag.tsx | Presentational component that displays a grant in a tag format |
Name | File | Description |
---|---|---|
EditKeywordModal | edit-keyword-modal.tsx | Allows editing an existing keyword |
KeywordPreview | keyword-preview.tsx | Shows a preview of a keyword's tags |
KeywordSelector | keyword-selector.tsx | Allows selecting a single keyword via a dropdown select |
KeywordTag | keyword-tag.tsx | Creates a colorful tag given a keyword |
NewKeywordModal | new-keyword-modal.tsx | Allows creating a new keyword |
Name | File | Description |
---|---|---|
Layout | layout.tsx | A simple layout component that contains the main content of the page and a footer |
Name | File | Description |
---|---|---|
FacultyLink | faculty-link.tsx | Given a faculty, creates a clickable link to the members table with this faculty as a filter |
MemberTypeLink | member-type-link.tsx | Given a member type, creates a clickable link to the members table with this type as a filter |
SafeLink | safe-link.tsx | A link that conditionally navigates, provided the user does not have unsaved changes. Otherwise opens the save changes prompt |
Name | File | Description |
---|---|---|
CardSkeleton | card-skeleton.tsx | Skeleton animation of a card used during loading |
CenteredSpinner | centered-spinner.tsx | An Ant Design Spinner styled to be centered on its parent |
Name | File | Description |
---|---|---|
AllMembers | all-members.tsx | The table of all members, includes filter components |
MemberInsightDescription | member-insight-description.tsx | Displays a member's insight information |
MemberInsightForm | member-insight-form.tsx | A form for editing a member's insight information |
PrivateMemberDescription | member-private-description.tsx | Displays a member's private information |
PrivateMemberForm | member-private-form.tsx | A form for editing a member's private information |
PrivateMemberProfile | member-private-profile.tsx | Top level component for viewing a member's profile, includes tabs and editing |
PublicMemberDescription | member-public-description.tsx | Displays a member's public information |
PublicMemberForm | member-public-form.tsx | A form for editing a member's public information |
PublicMemberProfile | member-public-profile.tsx | Top level component for viewing a member's profile, only includes public description |
MyProfileRegister | my-profile-register.tsx | A prompt for when a registered account navigates to My Profile but they are not registered in the members table, allows them to create an entry for themselves |
MyProfile | my-profile.tsx | Same as Private Member Profile, but references the active account |
Name | File | Description |
---|---|---|
Navbar | _navbar.tsx | Top level component, containing all other navbar components |
AvatarMenu | avatar-menu.tsx | Displays a signed in user's initials, includes a dropdown for displaying some account info and logging out |
HomeLogo | home-logo.tsx | The application logo and navigation to home |
LanguageButton | language-button.tsx | The button to switch the preferred language between english and french |
LoginButton | login-button.tsx | The button to initial authentication |
LogoutButton | logout-button.tsx | The button to logout |
NavMenu | nav-menu.tsx | The menu containing links to application routes |
Name | File | Description |
---|---|---|
AllPartners | all-partners.tsx | Displays a table of partners (organizations), with filters to filter the partners based on name, type and scope |
DeletePartnerButton | delete-partner-button.tsx | Allows an admin user to delete a partner from the system. |
PrivatePartnerProfile | partner-private-profile.tsx | A card which displays private information of a partner |
PublicPartnerDescription | partner-public-description.tsx | Displays the public information of a partner, including organization type, organization scope, and description. |
PublicPartnerForm | partner-public-form.tsx | Allows editing of public information of a Partner, such as name, type, scope, and description. |
PublicPartnerProfile | partner-public-profile.tsx | Displays the public information of an organization/partner |
OrganizationSelector | partner-selector.tsx | A form element that allows a user to search for partners/organizations and select one or more of them. |
OrganizationTag | partner-tag.tsx | A presentational component that displays a partner in a tag format. |
RegisterPartner | register-partner-member.tsx | A form that allows a user to register his partner |
RegisterPartner | register-partner.tsx | A form that allows an admin user to register a partner |
Name | File | Description |
---|---|---|
AllProducts | all-products.tsx | This is a component that displays a table of products , with filters to filter the products based on their title, types, authors. |
isAuthorMatch | author-match.tsx | this is a function that checks if the given author name matches the given first name and last name. |
DeleteProductButton | delete-product-button.tsx | This component is a button that opens a modal to delete a product. |
ProductAdminDescription | product-admin-description.tsx | This is a functional component that displays the private information of a product. |
ProductAdminForm | product-admin-form.tsx | This component is a form that allows to update the admin information of a product. |
PrivateProductDescription | product-private-description.tsx | This component displays private information about a product. |
PrivateProductForm | product-private-form.tsx | This component is used to display a form for editing the private information of a product. |
PrivateProductProfile | product-private-profile.tsx | This is a component that displays a private product profile. |
PublicProductDescription | product-public-description.tsx | This is a component that displays the public information of a product. |
PublicProductForm | product-public-form.tsx | This component is a form used to edit the public information of a product. |
PublicProductProfile | product-public-profile.tsx | This component displays the public profile of a product |
RegisterProduct | product-register.tsx | This component is used to register a new product in the system. |
ProductSelector | product-selector.tsx | This component is a form element that allows a user to search for products and select one or more of them. |
ProductTag | product-tag.tsx | This component is a presentational component that displays a product in a tag format. |
Name | File | Description |
---|---|---|
AllSupervisions | all-supervisions.tsx | This is a component that displays a table of supervisions , with filters to filter the supervisions based on supervision trainee name, faculty and level |
DeleteSupervisionButton | delete-supervision-button.tsx | This component is a button that opens a modal to delete a supervision. |
RegisterSupervision | supervision-member-register.tsx | This component is used to register a supervision trainee. |
PrivateSupervisionProfile | supervision-private-profile.tsx | A component that displays a private supervision profile. |
PublicSupervisionDescription | supervision-public-description.tsx | This component displays the public information of a supervision in a description list format |
PublicSupervisionForm | supervision-public-form.tsx | This is a form component that allows the user to edit public information of a supervision. |
PublicSupervisionProfile | supervision-public-profile.tsx | This component displays the public profile of a supervision. |
RegisterSupervision | supervision-register.tsx | This component is a form component to register a new supervision |
Users can navigate to the Events tab to initiate the exploration process. Here, an array of events awaits, as depicted in the illustrative figure below:
Upon spotting an event of interest, users can click on it to gain a summary view of the event. In the example below, Event 1 has been selected, leading to a dedicated page that presents a concise summary of the chosen event:
Under the summarized table mentioned above, a button placed and labeled "Journey of Event". Clicking this button provides a hierarchical view specific to the chosen event. The visualization below illustrates this feature:
In our development, we have categorized an event into two parts:
- Event Summary Expansion(
) ;
- Next Event Expansion(
)
In the event summary (), it displays the title of the event, type of event, start date and number of next event/s. Once a user expands, the user can see a detailed view of the member involved, grant, partner involved, and any product has resulted such as book, paper etc.
Once the user clicks on the (
), it will display the event's child event, if any.
To achiche the abouve pages, a journey folder was created at /src/pages/joe, followed by previous development. Following is the code in this folder:
import { useRouter } from "next/router";
import type { NextPage } from "next/types";
import { useContext, useEffect } from "react";
import { ActiveAccountCtx } from "../../../services/context/active-account-ctx";
import CardSkeleton from "../../../components/loading/card-skeleton";
import PageRoutes from "../../../routing/page-routes";
const PrivatejoePage: NextPage = () => {
const router = useRouter();
const { localAccount, loading } = useContext(ActiveAccountCtx);
const { id: idString } = router.query;
useEffect(() => {
if (!(typeof idString === "string")) {
return;
}
if (loading) return;
const id = parseInt(idString);
if (localAccount?.is_admin) {
router.replace(PageRoutes.privateEventProfile(id));
return;
} else {
router.replace(PageRoutes.publicEventProfile(id));
}
}, [localAccount, loading, idString, router]);
return <CardSkeleton />;
};
export default PrivatejoePage;
To route these page above, it has been added to our routing page at src/routing/page-routes.ts as joe: (id: number) => "/joe/" + id + "/private"
This application using SCSS which is a superset of CSS, so all vanilla CSS is still valid.
All SCSS files are located in src/styles
.
Only src/styles/_globals.scss
is imported into the application (in src/pages/_app.tsx
. So when creating a new stylesheet just import it into _globals.scss
.
If just doing a small amount of styling somewhere specific, you can always use React inline styling instead.