frontend & backend |
database |
Quickstart
- Install Node.js
- Start and migrate a Cockroach DB instance
- Run
npm run dev
Starting and migrating a CockroachDB instance in Docker
docker-compose up -d
Table of Contents
- Tutorials
- Repository Structure
- Update from master
- Run ESLint
- Frontend
- Backend
Repository Structure
public
: Static filessrc
: Source codesrc/components
: React Componentssrc/pages
: Next.JS Pagessrc/pages/api
: Next.JS API Endpointssrc/services
: Services used by the clientsrc/services/api
: Services used by the serversrc/utils
: Utility files used by the clientsrc/utils/api
: Utility files used by the servertests/models
: Model classes used by teststests/specs
: Test filestests/utils
: Utility files used by tests
How To?
Update from master
npm run sync
Run ESLint
npm run lint
Let ESLint automatically try to fix all errors by running
npm run fix
Run Tests
npm run test
Or to run a specific test:
npm run test -- -t "sets own biography"
How To? (Frontend)
Name Things
Avoid single letter names. Be descriptive with your naming.
- File names:
- Everything under
/src/pages
: lower-case-with-dashes - Other: Depending on the default export:
- Class/Component: PascalCase
- Other/No default export: camelCase
- Everything under
- Identifiers:
- Classes/Components: PascalCase
- Functions/Parameters/Variables: camelCase
- Database Tables/Columns: camelCase
Make Page
Add a file in the /src/pages
folder (but outside the /src/pages/api
folder).
It is a good idea to use our AppPage component to ensure consistency in layout across pages.
import AppPage from '../components/AppPage';
const MyPage = () => {
return (
<AppPage title="my title">
content goes here...
</AppPage>
);
};
export default MyPage;
Make Component
Split your UI into components. This improves performance and readability of your code. Use a component when you show the same thing in multiple places on the same page or across different pages.
Note: Leave business logic outside of components. A component should only be concerned with presenting data.
Add a file in the /src/components
folder.
CoolButton.js
import { IonButton } from '@ionic/react';
const CoolButton = ({ onClick, children }) => {
return (
<IonButton onClick={onClick} style={{ backgroundColor: 'lightblue' }}>{children}</IonButton>
);
};
export default CoolButton;
Usage elsewhere:
import CoolButton from '../components/CoolButton';
// ... later in code
<CoolButton onClick={clickHandler}>Click me, I'm cool</CoolButton>
Properties
Declare which properties your component needs in the parameters of your function. Do not forget the {}
around them!
const YourComponent = ({ name, color }) => { // use like <YourComponent name="bob" color="pink" />
Children Property
children
is a special property which contains the contents of your component.
See CoolButton example above.
Add CSS
Hold up! You should be using Ionic Components and the Ionic Grid System (read about them in the next sections).
Still convinced that you need CSS? Ok then...
Inline styles
For simple one-line CSS, use inline styles.
<IonButton style={{ backgroundColor: 'lightblue' }}>My Button</IonButton>
More info: https://www.w3schools.com/react/react_css.asp
CSS Modules
For more complex styling, use CSS modules.
Note: You should ONLY use CSS classes inside modules.
Note: Use camelCase in your class names here!
components/CoolButton.module.css
.coolButton {
background-color: lightblue;
}
components/CoolButton.js
import styles from './CoolButton.module.css';
import { IonButton } from '@ionic/react';
const CoolButton = ({ children }) => {
return (
<IonButton className={styles.coolButton}>{children}</IonButton>
);
};
export default CoolButton;
More info: https://github.com/css-modules/css-modules
Ionic
Check out the Ionic Components Documentation and select the right component for the job.
Example of a button:
import { IonButton } from '@ionic/react';
const MyPage = () => {
const clickHandler = () => {
// Button was clicked, do something!
};
return (
<IonButton onClick={clickHandler}>Click me!!!</IonButton>
);
};
export default MyPage;
Not supported components:
IonTab (but you can use IonTabBar), IonVirtualScroll, IonRouter
Ionic Grid System
Ionic Grid Documentation: https://ionicframework.com/docs/layout/grid TODO
Add Image
Images should be placed inside the /public/img/
folder.
<IonImg src="/img/myimage.png" alt="Description of the image." />
More info: https://nextjs.org/docs/basic-features/static-file-serving
Add Form
Use react-hook-form
to create forms. Use SubmitButton
for the submit button.
import { useForm } from 'react-hook-form';
import IonController from '../components/IonController';
import { IonButton, IonInput } from '@ionic/react';
import SubmitButton from '../components/SubmitButton';
import { onSubmitError } from '../utils/errors';
const MyPage = () => {
const { control, handleSubmit } = useForm();
const onSubmit = ({ firstItem, secondItem }) => {
// submit button was clicked, do something
};
return (
<form onSubmit={handleSubmit(onSubmit, onSubmitError)}>
<IonController type="text" as={IonInput} control={control} name="firstItem" />
<IonController type="text" as={IonInput} control={control} name="secondItem" />
<SubmitButton>Submit</SubmitButton>
</form>
);
};
export default MyPage;
IonController
IonController
is our compatibility bridge between react-hook-form
and Ionic
.
You must put each form field into its separate IonController
!
Example with IonInput:
// short form
<IonController type="text" as={IonInput} control={control} name="field1" />
// long form
<IonController control={control} name="field1" as={
<IonInput type="text" as={IonInput} />
} />
Example with IonRadioGroup:
<IonController control={control} name="manufacturers" as={
<IonRadioGroup>
<IonListHeader>
<IonLabel>Manufacturers </IonLabel>
</IonListHeader>
<IonItem>
<IonLabel>Apple</IonLabel>
<IonRadio value="apple" />
</IonItem>
<IonItem>
<IonLabel>Microsoft</IonLabel>
<IonRadio value="microsoft" />
</IonItem>
</IonRadioGroup>
} />
IonFileButtonController
For uploading file, you need the specialized IonFileButtonController
, as the IonInput
and react-hook-form
libraroes are both incompatible with file inputs.
Usage:
import { IonFileButtonController } from '../components/IonController';
import { toBase64 } from '../utils/fileUtils';
import SubmitButton from '../components/SubmitButton';
import { withLoading } from '../components/GlobalNotifications';
import { onSubmitError } from '../utils/errors';
const MyPage = () => {
const { control, handleSubmit } = useForm();
const onSubmit = withLoading(async ({ myfile }) => {
const myfileBase64 = myfile ? await toBase64(myfile) : null;
// do something with the contents
});
return (
<form onSubmit={handleSubmit(onSubmit, onSubmitError)}>
<IonFileButtonController control={control} name="myfile">Select file</IonFileButtonController>
<SubmitButton>Submit</SubmitButton>
</form>
);
};
export default MyPage;
Input validation & errors
Use onSubmitError
helper function to show an alert when the form rules are not fulfilled. Use the error
obejct to show error messages right along the relevant fields.
import { useForm } from 'react-hook-form';
import IonController from '../components/IonController';
import { verifyEmail } from '../utils/auth/isValidEmail';
import { IonButton, IonInput } from '@ionic/react';
import SubmitButton from '../components/SubmitButton';
import { onSubmitError } from '../utils/errors';
const MyPage =() => {
const { control, handleSubmit, error } = useForm();
const onSubmit = ({ firstItem, email }) => {
// submit button was clicked and all validation passed
}
return (
<form onSubmit={handleSubmit(onSubmit, onSubmitError)}>
<IonController type="text" as={IonInput} control={control} name="firstItem" rules={{ required: true, maxLength: 50 }} />
{errors.firstItem?.type === "required" && "Your input is required"}
{errors.firstItem?.type === "maxLength" && "Your input exceed maxLength"}
<IonController type="email" as={IonInput} control={control} name="email" rules={{ validate: verifyEmail }} />
{errors.email && "Your email is invalid"}
<SubmitButton>Submit</SubmitButton>
</form>
);
};
export default MyPage;
Dynamic UI Updates
Instead of waiting for the user to press Submit
, you might want to update the UI as soon as the user types their input.
Use the watch
API for this purpose.
const { control, watch } = useForm();
const userInput = watch('userInput'); // this variable updates as the user types
return (
<form>
<p>Your input is: {userInput}</p>
<IonController type="text" as={IonInput} control={control} name="userInput" />
</form>
);
Show Alert
import { makeAlert } from '../components/GlobalNotifications';
await makeAlert({
header: 'Woah!',
message: 'I am an alert.',
});
For a list of supported properties, see: https://ionicframework.com/docs/api/alert
Show Toast
import { makeToast } from '../components/GlobalNotifications';
await makeToast({
message: 'Hey there! 👋',
});
For a list of supported properties, see: https://ionicframework.com/docs/api/toast
Make API Call
Use API calls to communicate with the server from the client.
GET Call
The SWR helper library helps you fetch data from the server.
Place code that facilitates interaction with external services in the services
folder.
services/userData.js
import useLoadingSWR from 'swr';
export const useUserData = (userId) => {
return useLoadingSWR(`/api/getUserData?userId=${userId}`);
};
Usage elsewhere:
import { useUserData } from '../services/userData';
import { useOnErrorAlert } from '../utils/errors';
const MyPage = () => {
const { data, error } = useOnErrorAlert(useUserData('ABCD'));
if (error) return "failed to load";
if (!data) return "loading...";
return (data.message);
};
export default MyPage;
Show API Error (GET Call)
The helper function useOnErrorAlert
shows an alert when an API fails to load.
import { useOnErrorAlert } from '../utils/errors';
import { useUserData } from '../services/userData';
const MyPage = () => {
const { data, error } = useOnErrorAlert(useUserdata('ABCD')); // automatically shows an alert on error
if (error) return "failed to load";
if (!data) return "loading...";
return (data.message);
};
export default MyPage;
POST Call
Place code that facilitates interaction with external services in the services
folder.
services/userData.js
import fetchPost from '../utils/fetchPost';
export const updateUserData = async (userId, firstName, lastName) => {
return await fetchPost('/api/updateUserData', {
userId,
firstName,
lastName
});
};
Usage elsewhere:
import { updateUserData } from '../services/userData';
import { withLoading } from '../components/GlobalNotifications';
import { makeAPIErrorAlert } from '../utils/errors';
// later in code
const clickHandler = withLoading(async () => {
const user;
try {
{ user } = await updateUserData(123, "Bob", "Smith");
} catch (ex) {
makeAPIErrorAlert(ex);
return;
}
// do something with the updated user
});
Show API Error (POST Call)
The helper function makeAPIErrorAlert
shows an alert if the API throws an error.
import { updateUserData } from '../services/userData';
import { withLoading } from '../components/GlobalNotifications';
import { makeAPIErrorAlert } from '../utils/errors';
// later in code
const clickHandler = withLoading(async () => {
const user;
try {
{ user } = await updateUserData(123, "Bob", "Smith");
} catch (ex) {
makeAPIErrorAlert(ex);
return;
}
// do something with the updated user
});
How To? (Backend)
Make GET API
Add a file in the /src/pages/api
folder.
Example:
import handleRequestMethod from '../../utils/api/handleRequestMethod';
import withSentry from '../../utils/api/withSentry';
const doSomething = async (req, res) => {
// make sure this is a GET call
await handleRequestMethod(req, res, 'GET');
// get parameters
const { userId } = req.query;
if (userId == null) {
// this is an error
// use 4XX codes for user error and 5XX codes for server errors
return res.status(400).json({ code: 'auth/no-user-id' });
}
// empty json to confirm success
return res.json({});
};
export default withSentry(doSomething);
Make POST API
Add a file in the /src/pages/api
folder.
Example:
import handleRequestMethod from '../../utils/api/handleRequestMethod';
import withSentry from '../../utils/api/withSentry';
const doSomething = async (req, res) => {
// make sure this is a POST call
await handleRequestMethod(req, res, 'POST');
// get parameters
const { userId, firstName, lastName } = req.body;
if (userId == null) {
// this is an error
// use 4XX codes for user error and 5XX codes for server errors
return res.status(400).json({ code: 'auth/no-user-id' });
}
// empty json to confirm success
return res.json({});
};
export default withSentry(doSomething);
Error Codes
User the format <area>/<error-name>
for your error codes.
The utils/error.js
file translates these errors to user readable error messages.
Throw an error in the server like this:
return res.status(400).json({ code: 'myarea/some-error' });
Use Request Method
With the help of handleRequestMethod
, you can make sure that your API is only called with a given method (either POST or GET).
Usage example:
import handleRequestMethod from '../../utils/api/handleRequestMethod';
import withSentry from '../../utils/api/withSentry';
const doSomething = async (req, res) => {
await handleRequestMethod(req, res, 'POST');
// rest of your code
};
export default withSentry(doSomething);
Use Authentication
With the help of authMiddleware
, you can be sure that your API is only called with authenticated users.
Usage example:
import handleRequestMethod from '../../utils/api/handleRequestMethod';
import authMiddleware from '../../utils/api/auth/authMiddleware';
import { verifyLecturer } from '../../utils/auth/api/role';
import withSentry from '../../utils/api/withSentry';
const myAPI = async (req, res, { userId, role }) => {
await handleRequestMethod(req, res, 'GET');
// userId and role are available here
// verify user request
try {
verifyLecturer(role);
} catch ({ code }) {
return res.status(400).json({ code });
}
};
export default withSentry(authMiddleware(myAPI));
Query Database
Database functionality is provided through helper function in /src/services/api/database/
folder.
These are organized based on the functionality of the application.
You may call these functions from the backend code.
Example helper function:
import { databaseQuery } from '.';
export function getEmailFromUser(userId) {
const queryText = 'SELECT email FROM users WHERE userId = $1';
const params = [userId];
return databaseQuery(queryText, params);
}
Database Transaction
Use databaseTransaction
to run multiple SQL statements in a transaction.
import { databaseTransaction } from '.';
export function getEmailFromUser(userId) {
return databaseTransaction(async client => {
const queryText = '<TRANSACTIONAL SQL HERE>';
const params = [userId];
client.databaseQuery(queryText1, params);
});
}
Testing
We use Jest for testing. Tests are written inside define
blocks.
Each test
is a function that verifies some functionality.
You can write tests to verify that your backend behaves as you expect it to. Your tests may setup a database state, run an API function, and verify the results and the database state.
Example
import { setBiography } from '../../src/services/users';
import setLogin from '../utils/setLogin';
import { addTestLecturer, addTestStudent, addTestSuperuser } from '../models/User';
describe('biography', () => {
test('sets own biography', async () => {
const user = await addTestStudent();
await setLogin(user);
const result = await setBiography(user.userid, 'Hello world');
expect(result).toStrictEqual({});
await user.refresh();
expect(user.biography).toBe('Hello world');
});
});
Setup the database
There are many helper functions to help you setup a desired database state. You should NOT work with existing users and courses to ensure that tests can run independently.
Helper methods exist to create all sorts of objects. These methods automatically delete all created objects after the test is complete.
To create a course, use addTestCourse()
. You may optionally specify a title and year code.
To create a user, use addTestLecturer()
, addTestStudent()
, or addTestSuperuser()
functions.
To add a user to the course, use course.addAttendee({ userid: user.userid })
. To get all attendees, use course.getAttendees()
.
To create a homework, use course.addHomework()
. To get all homeworks, use course.getHomeworks()
.
To submit a solution, use homework.addSolution({ userid: user.userid })
. To get all solutions, use homework.getSolutions()
.
To add a review, use solution.addReview({ userid: user.userid })
. To get all reviews, use solution.getReviews()
.
To add an audit, use solution.addAudit()
. To get all audits, use solution.getAudits()
.
To reload an object, use .refresh
:
await user.refresh();
To edit an object, use:
await review.set({
issubmitted: true,
percentagegrade: 100,
reviewcomment: 'Well done!',
});
Call a backend endpoint
To login as a user, use setLogin(user)
. If you do not login, you will access the APIs as a guest.
You may use fetchGet
or fetchPost
, or any function that uses these internally, to test the backend APIs.
Functions using useSWR
/ useLoadingSWR
are currently not supported, please add a helper function calling fetchGet
in these instances.
Verify results
Jest offers many helpers to verify the results of a test.
You must always use expect(x)
to create an expectation object.
Expectation objects offer multiple methods to be compared with the expected results:
- toBe (equality check)
- toStrictEqual (check contents, used for objects)
- toBeNull, toBeSmallerThan, ...
To test for exceptions, use .rejects
:
await expect(async () => {
await setBiography(student.userid, 'Hello world');
}).rejects.toStrictEqual({ code: 'auth/unauthorized' });