Last updated by Gabe Abrams in 2024. This will probably be out of date within minutes of this guide being published. Be alert and be flexible.
<style> /* Rule element for team rules */ Rule { display: block; font-weight: bold; color: #CB3048; border: 0.15rem solid #CB3048; border-top-left-radius: 0.3rem; border-top-right-radius: 0.3rem; padding-top: 0.2rem; padding-bottom: 0.2rem; padding-left: 0.5rem; padding-right: 0.5rem; } Rule::before { content: '\2605 Rule: '; font-weight: normal; } /* for rules that don't have a following pre with explanation */ Rule[tall] { margin-bottom: 0.7rem; border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } /* Extend rule border around following pre with explanation */ Rule + .highlighter-rouge pre { border-bottom: 0.15rem solid #CB3048; border-left: 0.15rem solid #CB3048; border-right: 0.15rem solid #CB3048; border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } /* Exercise block with an assignment */ Exercise { display: block; font-weight: bold; color: #54d2e2; background-color: white; position: relative; z-index: 10; border: 0.15rem solid #54d2e2; border-top-left-radius: 0.3rem; border-top-right-radius: 0.3rem; padding-top: 0.2rem; padding-bottom: 0.2rem; padding-left: 0.5rem; padding-right: 0.5rem; } Exercise::before { content: '\270F Exercise: '; font-weight: normal; } /* for exercises that aren't followed by a pre with explanation */ Exercise[tall] { margin-bottom: 0.7rem; border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } /* Extend exercise border around following pre with explanation */ Exercise + .highlighter-rouge pre { border-bottom: 0.15rem solid #54d2e2; border-left: 0.15rem solid #54d2e2; border-right: 0.15rem solid #54d2e2; border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } /* Example response extend below exercise as a visual add-on */ details { margin-left: 1em; margin-right: 1em; position: relative; transform: translate(0, -1rem); padding-left: 0.5rem; padding-right: 0.5rem; padding-top: 0.3rem; padding-bottom: 0.3rem; background-color: #54d2e2; border-bottom: 0.15rem solid #54d2e2; border-left: 0.15rem solid #54d2e2; border-right: 0.15rem solid #54d2e2; border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } /* Fix spacing below pre inside of example */ details pre { margin-bottom: 0.3rem !important; } /* Bold, white text for example title */ summary { text-align: center !important; font-weight: bold; color: white; } /* Highlight color for code inside of example that's important code */ details b { font-weight: bold; color: #d14; } /* Boxes around main titles */ h1 { border-radius: 0.5rem; background-color: #333; color: white; padding-bottom: 0.5rem; padding-left: 0.7rem; padding-right: 0.7rem; padding-top: 0.5rem; } /* Hide footer */ .footer { display: none; } /* Wrap text in text-based pre tags */ pre[text] { white-space: pre-wrap; } </style>Although everything in this guide should be followed closely, pay special attention to Rule
blocks, as those are even more non-negotiable.
Getting Set Up:
Team Info:
Programming and Testing:
- Project and File Management
- Typescript Basics
- Typescript Advanced Stuff
- React
- Logging
- Express Server
- React Component Unit Tests
- Helper Unit Tests
- Automated UI Testing
- Commonly Used Dependencies
Creating Projects:
Note: if using windows, start by installing a terminal replacement like Git Bash. It must function like a Mac OS or Linux machine. Otherwise, virtualize Linux.
In each new project, switch to standard unix-style line breaks:
git config core.autocrlf false
git rm --cached -r .
git reset --hard
Always make sure your git config is set to detect case-sensitive filename changes:
git config --global core.ignorecase false
Javascript environment:
- Install NVM
- Install Node.js LTS via NVM
NPM setup
- Install npx using
npm i -g npx
# Open terminal, check the version you're running:
username@MyLaptop % node --version
x.x.x
# Change to a different version of node
username@MyLaptop % nvm use 16
# Make sure the version switched
username@MyLaptop % node --version
16.x.x
# Switch back to the current version
username@MyLaptop % nvm use default
# Open the Node interpreter
username@MyLaptop % node
# Test Node operations
Welcome to Node.js v18.15.0.
Type ".help" for more information.
> console.log(10 + 5);
15
> const name = 'Divardo';
Divardo
>
Install required VSCode extensions:
Code Spell Checker
by Street Side Software - flags spelling errors in your codeESLint
by Microsoft - required for eslint enforcementnpm
by Microsoft - required for code highlightingTODO Highlight
by Wayou Liu (alternative is okay) - highlights TODOs
Remove banned extensions or disable them on all our projects:
Prettier
- formats in ways that do not necessarily follow our rules
If you want, install optional VSCode extensions:
Path Intellisense
by Christian Kohler - helps auto-complete local importsnpm Intellisense
by Christian Kohler - helps auto-complete lib importsgitignore
by michelemelluso - right click and add to gitignoreThunder Client
by Ranga Vadhineni - lets you easily send api requests for testingDuplicate action
by mrmlnc - lets you easily duplicate filesvscode-icons
by vscodeicons.team - makes folders much more visual in the file tree
Update these VSCode settings:
- Change "EOL" style to "\n" (LF)
- Use two spaces instead of tabs
- Emmet: Show Expanded Abbreviation = inMarkupAndStylesheetFilesOnly
- Turn on "Bracket Pair Colorization"
- Typescript Preferences > Quote Style = "single"
Troubleshooting ESLint:
If eslint isn't working on the project, first try reinstalling it: Shift + CMD/Ctrl + P > Reinstall Extension > ESLint.
If that doesn't fix it, try the solutions in this ESLint Troubleshooting Checklist.
If opening the project in VSCode, try only opening the project itself (not a parent directory).
- Create a folder on your machine, call it
ts-sandbox
- Visit the folder in terminal and run
npm init -y
(don't add the "-y" except for creating sandboxes) - Run
npm init dce-eslint@latest
and confirm to set up eslint - Install node types by running
npm install --save-dev @types/node
- Open the
.eslintrc
file and remove the following lines:'react-app',
and'react-app/jest',
under theextends
section - Create a
tsconfig.json
file in the top directory and paste in the following content:{ "compilerOptions": { "target": "es5", "lib": [ "esnext" ], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "commonjs", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": false, "outDir": "./build", "rootDir": "./src", "removeComments": false, "declaration": true }, "include": [ "src" ] }
- Create a
/src
folder. Note that all your sandbox typescript files should go into this folder
Each time you want to create a new sandbox file, simply go into the /src
folder and create a new nameOfFile.ts
file.
To run one of your sandbox files:
- In the terminal, navigate to the
/src
folder - Run
ts-node nameOfFile.ts
Note: if you get an error saying ts-node: command not found
, install ts-node
using npm install -g ts-node
Visit the CACCL Docs and set up a react-based project, using npm init caccl@latest
.
If you don't have a HarvardKey, claim your HarvardKey:
- Visit the HarvardKey page: https://key.harvard.edu/
- Click "Claim HarvardKey"
- Follow instructions
Set up Direct Deposit:
- Make sure you can log into https://peoplesoft.harvard.edu
- Follow instructions in the "Direct Deposit" PDF that HR sent you via email when you signed paperwork
Become familiar with reporting time via Peoplesoft:
- Make sure you can log into https://peoplesoft.harvard.edu
- Follow instructions in the "Reporting Time and Absences" PDF that HR sent you via email when you signed paperwork
Get access to Canvas:
- Visit Canvas https://canvas.harvard.edu
- Log in
- If you get an error message, contact Gabe and they will help you continue. If you are able to get in, you're all set
Generate a Canvas access token:
- Visit Canvas https://canvas.harvard.edu
- Click your profile image
- Click "Settings"
- Scroll down and click "+ Access Token"
- Set the purpose to "DCE EdTech Testing" and set the expiry date for the end date of your employment term with DCE
- Copy down the access token into a secure place (on mac, I recommend using notes in the built-in keychain app, otherwise a password manager will do)
Get access to the team slack:
- Send Gabe your preferred slack email address and they will add you
Get access to GitHub projects:
- Send Gabe your GitHub handle and they will add you to the appropriate projects
Everything we do will be aligned with our mission. The skills we develop, the projects we work on, and the designs we create will all be informed by and aligned with our mission.
What is a mission? It is something to align your work with. It is not something that we expect to achieve, but rather, something that we expect to move toward. We think of our mission more as a direction than a mandate.
Focus on diversity, equity, inclusion, and belonging: build tools that use inclusive language, foster a sense of belonging, and empower users to be themselves.
Advance accessibility and improve access: meet and exceed accessibility standards, provide solutions for people with mobile devices, and prepare for weak internet connections and slow devices. Use simple, standard language.
Design for synchronous, asynchronous, in-person, and distance users: incorporate flexible teaching and learning pedagogy into the technology we build.
Support increased scale: build to enable the increasing number of courses and term startups.
Design for self-service administration and support: all admin functionality, debugging, analytics, and tracking must be exposed through a simple and intuitive admin dashboard. Software developers should not be required for diagnosing issues, debugging user behavior, or handling any tool-specific non-bug issues.
Design for self-service usage: teaching team members and students should be able to perform as much as possible on their own before requiring the attention or assistance of a staff member.
Choose efficient and scalable technologies: use simple and scalable technologies, shift as much of the complex logic as possible to the front end.
Always choose the simplest technology: whether it's a small or large library, always choose the simplest tech so we can focus on impact. This helps users understand our tools and helps programmers work on and understand each other's code.
Standardize design and UI: to create a seamless user experience, we will use the same design language and user interface elements across all projects. Gabe oversees the designs and makes sure UI and UX decisions are consistent across all tools. We also design to match the design of 3rd party tools (Canvas, Zoom, etc.) to create a seamless user experience.
Standardize dependencies and libs: every API, every 3rd party tool, and every small dependency should be standardized across all projects. For example, CACCL always handles Canvas integration, ZACCL always handles Zoom integration, and so on. With libraries, we always use the same library for cloning, for example. This reduces cognitive load while reducing app bundle sizes because of merged auto-bundling.
Meet users where they are: always design to meet the user where they are. Have buttons in convenient locations, place relevant actions in locations that will be visible when those actions need to be performed.
Write human-readable error messages and codes: all errors must have both a human-readable error message and a machine-readable error code. Our error messages are written to be short, concise, and not require any technical or domain-level knowledge and definitely do not expose any of the inner workings of our products.
Know your users: we design our tools for staff, teaching team members, and students. With each project, we'll take time to talk about and learn about our users.
Listen to teaching and learning experts: combine your own passion about education with the expert advice, attention to research, and studies/focus groups that the teaching and learning team conducts. Gabe serves as the team's representative on the teaching and learning team, explaining updates and new findings as they come from the teaching and learning team.
Prioritize impact over tech: we design for impact, we design to improve our courses, help students learn and find community, and help TTMs (Teaching Team Members) teach in exciting and engaging ways. Prioritizing impact over tech means that impact comes first and then we find the technologies that make that impact possible, instead of choosing technologies that we like and then seeing how we can make an impact with them.
Collect data and provide analytics: work to collect data and analytics to help improve our tools and improve our courses, and create dashboards that provide insight to the teaching and learning team. This is important but cannot be an excuse for compromising student safety and privacy.
Let's take a moment to go over the DCE Senior Leadership Team's DCE-wide list of goals for 2023 to 2025 and take a moment with each one to work on placing our work within that greater vision.
DCE is working to grow its enrollments, so this is a general mission targeted at increasing the size of our student population, thus increasing our budgets. This mission is broad and covers the DCE marketing team, enrollment services, alumni network, and lots of outreach and community work. Although our work might not obviously fit into the work of recruitment, we do have the ability to make an impact here.
One thing we see over and over again is that, with online eduction, powerful and seamless technologies are becoming the baseline for what students expect. Of course, this connects with our mission: Seamless and Intuitive
. That said, student's don't stop there; they search for learning environments that give them a sense of community, a sense of belonging, the opportunity to build peer networks, and the feeling that our programs and technologies are human-centered and learning-centered. That is why our Informed and Impactful
mission is key with this DCE-wide mission. If we can create informed and impactful technologies, students will see that our technologies help them learn and they will return for more classes, tell others about our courses, and will share positive reviews and build the DCE brand. Finally, surveys show that students stick around because they feel a sense of community and purpose. We don't just want students who come for one class and then leave. We want students who feel like they're part of something bigger, students who feel like they belong and are part of our community. Those are the students who come back and grow our enrollments. That's why our Inclusive and Flexible
mission is so important here.
We advance this DCE-wide mission through our Seamless and Intuitive
, Informed and Impactful
, and Inclusive and Flexible
missions.
B. Enhance future-focused student-centered engagement philosophy and built strategies to support it for both new and returning students
This DCE-wide mission articulates what is our bread and butter: creating student-centered engagement opportunities that push the envelope of what's possible with technology. We are the EdTech Innovations team and we are a huge part of bringing this DCE-wide mission to life. Our work, partnered with other Teaching and Learning Team work on teaching practice and pedagogy, builds the "strategies" to support new and returning students. In summary, our team exists because of this DCE-wide mission.
We advance this DCE-wide mission through the combination of all of our missions because all of our projects are chosen to advance this mission.
This DCE-wide mission focuses on smaller engagement opportunities (weekends, professional development programs, etc.) that require enormous scaling all while maintaining a standard of pedagogy-first design and student-centered engagement. Because these shorter programs happen more frequently and in larger numbers, it becomes really important that we build tools that help with scalability and make self-service more available. For example, instead of three rounds of setting up courses (Spring, Summer, Fall), we might need to set up new courses every few weeks. This is where our Scalable and Self-Service
mission becomes critical. We simply don't have the staff time to make this new shorter term programming model possible, and so we turn to scaling and self-service.
We advance this DCE-wide mission through our Scalable and Self-Service
mission.
D. Recruit and engage FAS (and Harvard) faculty to teach in DCE programs through targeted outreach strategies
This DCE-wide mission aims to make it very appealing for Harvard teachers to join our programs. Other teams at DCE work on outreach. The Teaching and Learning Team is called in once faculty have already decided to join DCE. Our contribution to this mission is to make that onboarding process as easy as possible (our Seamless and Intuitive
mission), give teachers tools and insight that they don't get at other parts of Harvard (the Self-Service
part of our Scalable and Self-Service
mission), tools that help them teach in exciting new ways (our Informed and Impactful
mission), and give them flexibility to teach how they dream of teaching (the Flexible
part of Inclusive and Flexible
).
We advance this DCE-wide mission through our Scalable and Self-Service
, Informed and Impactful
and Inclusive and Flexible
missions.
This DCE-wide mission is pretty simple: the goal is to use our summer programs as an opportunity to test out new teaching practices and tools. We're already doing this because most of our projects start their pilots during the summer.
We advance this DCE-wide mission not through our own missions, but through our piloting schedule and process.
F. Create and execute an international strategy meant to drive enrollments, increase classroom diversity, and further cultivate a DCE global presence
This DCE-wide mission aims to bring in students across the world, increase our enrollments, and increase classroom diversity. There are two steps to this DCE-wide mission: first, we need to recruit those students and bring them into our programs, second, we need to help those students learn and feel like they belong. The first step is driven by our marketing and enrollment services teams, but the second step is driven by the Teaching and Learning Team and by the tools that we build. In particular, especially with international students, we need tools that are extremely scalable and lightweight (slow internet connections, VPNs), but also self-service so that they can use our tools during hours where our teams are offline, thus calling forth our Scalable and Self-Service
mission. It's not enough to simply have a more diverse student body. We need to embrace their different time zones, educational backgrounds, identities, cultures, and learning styles. Thus, we build tools that are both Informed and Impactful
and Inclusive and Flexible
.
We advance this DCE-wide mission through our Scalable and Self-Service
, Informed and Impactful
and Inclusive and Flexible
missions.
G. Identify/create explicit opportunity pairings between academic units (EXT, HSS, PDP, HILR) and departments.
Many of our tools are used across all DCE courses and programs. Thus, creating Informed and Impactful
tools already requires us to work across different academic units. The very structure of our team is already situated between two teams, so it's fair to say that we are the embodiment of such explicit opportunities for collaborative pairings.
We advance this DCE-wide mission through our Informed and Impactful
mission.
This DCE-wide mission is mostly targeted toward our curriculum planning and program development groups at DCE. That said, their work requires new technologies to scale the new programs and degree offerings. Once these programs are created, we will begin to work with program heads and experts on the Teaching and Learning Team to identify projects to advance this DCE-wide mission.
We will work to advance this DCE-wide mission once our new master's degree offerings have been developed. Gabe is in meetings where they will get updates on the progress of this work and they will loop in the team once those projects have matured to the point where clear technologies are needed.
This DCE-wide mission aims to create other reasons for students to come to our programs, instead of only coming for credit.
We will work to advance this DCE-wide mission once our noncredit strategy has been developed. Gabe is in meetings where they will get updates on the progress of this work and they will loop in the team once those projects have matured to the point where clear technologies are needed.
Our technologies were chosen to fit our missions. Thinking about consistency, stability, scalability, etc. we've landed on the following stack:
Front-end: Typescript, React + SCSS + Bootstrap
APIs: REST
Server-side: Typescript, Node + Express
Databases: Mongo/Amazon DocDB
Our top priority is health, wellbeing, a sense of belonging, and a feeling that you can bring your full authentic self to work. In some ways, this means a culture of learning, flexibility, and kindness: we will work hard to flex our work processes to be mindful of teammates' health, wellbeing, and sense of belonging. In some ways, this means inflexibility where necessary in order to create a culture of uncompromising inclusion: we require usage of people's names and pronouns and respect for people's cultures and identities.
Health is extremely important. Remember that flexible work arrangements and workplace accommodations are available!
To keep everyone healthy and safe, team members agree to not join in-person meetings while sick in any way. This includes colds and other minor sicknesses. Further, if a teammate isn't well enough to work or meet, we fully support that teammate in taking time to fully recover. This includes physical health, mental health, and general wellbeing.
We work hard to create safe spaces for everyone to feel like they can be their authentic selves at work. That said, it is everyone's choice to share. To advance the feeling of a safe space, we adopt a "model, don't mandate" mentality. Gabe might share things about themselves to create a space of openness, but this doesn't mean that sharing is required in any way. For example, Gabe will share their pronouns, but you needn't do that. If you don't share your pronouns, Gabe will simply not use gendered pronouns when referring to you and it'll be easy as pie.
We work hard to have flexible hours, schedules, and workloads to account for stuff in our lives that creates stress or anxiety (if possible, give warning to minimize impact on other team members).
Because we're part of a collaborative team, equity becomes important. Thus, while prioritizing flexibility and work-life balance, we must be careful that such flexibility does not encroach on other teammates own wellbeing, flexibility, and work-life balance. Basically, we don't grant flexibility where it will cause hardship to another teammate, where it will create blockers for other people's creativity or work, or where it will impact another teammate's wellbeing.
We build a culture of constructive feedback. Gabe welcomes (and practically begs for) feedback. In return, Gabe works hard to give feedback to everyone on the team. If you ever feel unsure about how you're doing, please ask for feedback. Good conversations and sharing of feedback are how we create clarity and openness.
All feedback should be structured in a way that is constructive and respectful. In return, when receiving feedback, start by assuming that the person giving feedback is doing it with the best intent.
We use Slack for communication. If communication is happening outside of Slack, ping people in Slack. For example, if you left comments on someone's GitHub PR, ping them in Slack with a link to the GitHub PR. We expect prompt communication (get back to people within a day or two) but we only expect responses from each other on Monday, Tuesday, Wednesday, and Thursday (not Friday) from 9am to 4pm ET. Note: it's okay to send messages outside of hours, but there's no expectation to respond.
If you're very busy and don't have time to fully respond within one or two days, try to acknowledge the message (a quick thumbs up, for example) or give a time frame (e.g. "super busy, can we talk next week?"). Prompt communication is important because that teammate might be relying on you or blocking on you, but remember that we want to be kind and understanding; we all have a lot on our plates, so understand that prompt communication can be tough.
If you're someone who often takes up more space than others, try taking a little less space and leaving room for others to speak and contribute. If you're someone who often takes up less space than others, challenge yourself to speak and contribute more. Taking space can take multiple forms depending on collaboration style - not just speaking up in meetings, but also things like contributing ideas via chat or Slack, offering help and giving feedback, helping with organization and brainstorming, and so much more.
We don't schedule regular meetings for Fridays and we don't schedule any meetings on Saturdays or Sundays.
Be punctual to meetings, end meetings on time. For foreseeable circumstances, give at least 2 days of notice if you can't attend a meeting or if you need to cancel/move a meeting. Be understanding for emergency circumstances.
If you need help, ask for it. If you're worried that you're falling behind, running into roadblocks, or are making a big decision and need help thinking through the decision, ask for help. In exchange, take time to offer help as well. It's hard to ask for help. This takes effort, but it's worth it! We all help balance each other's workloads if we get help when we need it and offer help when we have extra bandwidth.
Time that you take to help others on the team is considered part of your work. This is not "above and beyond" work, this is not an extra thing on top of your work. Thus, of course, it goes on your time sheet and may mean that you will need rethink the schedule of your own work. Chat with Gabe to re-set expectations, deadlines, schedules, etc. instead of trying to squeeze everything in, potentially adding stress or tension for yourself.
Please share your thoughts on the norms, suggest new norms, and help improve our norms.
Come up with one challenge that we might face (tough deadlines, misunderstanding, someone said something hurtful, etc.)Example Result
Two teammates are working together and one person is dictating how the code should be developed while not listening to the other person's ideas
Example Result
The most relevant norm would be "Take Space, Make Space" because it seems that one of the teammates doesn't have space to share their thoughts and opinions on the direction of the project.Either the teammates could work it out together or one of them might ask Gabe for advice and Gabe could remind people of the "Take Space, Make Space" norm, asking the dominant team member to make space for the other teammate to share.
We use GitHub Projects as our task management system. To see what you're assigned, visit the repo, click Projects
, click our project, and take a look at the Assigned
column. Cards with your picture on them are yours to do this week.
We'll be adding cards together, and sometimes you'll be in charge of creating cards to represent the tasks and work that you're doing. This is meant to be a collaborative process. Instead of purely dictating tasks, Gabe likes to discuss the task and then either work together to fill out the card or leave the creation of the card up to you. When you create cards, write down your understanding of the task, and Gabe will use it as a way to make sure everyone's on the same page about what the task entails.
When you start working on a task, drag the card to In Progress
.
When you finish a task, drag the card to In Review
.
We'll peer-review each-other's work before moving cards to Done
.
The main branch of the project is managed by Gabe. Anything that makes it to main
is Gabe's responsibility, so please do not merge into main
.
For each task you work on, check the title and type of the card in GitHub Projects. If there is no card, do your best to determine the type of work you're doing. That type may be "enhancement" or "feature" or "bugfix" etc.
Before starting to work on that task, create a new branch labeled <type>/<lowercase-dashed-title>
.
# For an "update" task called "Add Simplification Algorithm"
git checkout stage
git pull
git checkout -b update/add-simplification-algorithm
For your own public credit on GitHub and for traceability, we prefer that you commit and push to your branch very often.
Commit and push to your branch frequently# Always push after you commit
git add ...
git commit -m "description of what you did"
git push ...
When done with your task, submit a pull request to stage
and describe everything you did in detail. Finally, request a review from a peer or from Gabe and let everyone know in Slack.
We use a strict file structure for all our projects. This helps us get around each other's code and greatly improves modularity and encourages code reuse.
Modules can take one of two structures: one/two files or folder. A module must be a folder if it has any helpers, sub-components, or more than two files for any other reason. There is one exception to this: test files are not included in this count, so you may end up with three files instead of a folder if one of them is a test.
Modules must be folders if they have helpers, sub-components, or more than 2 files// Allowed:
MyComponent.tsx
MyComponent.scss
// BUT MUST BE A FOLDER...
// If there are 3+ files:
MyComponent/
index.tsx
style.scss
logo.png
// OR
// If there are helpers:
MyComponent/
index.tsx
helpers/
myHelper.tsx
// Or if there are sub-components:
MyComponent/
index.tsx
MySubComponent.tsx
MySubComponent.scss
Our npm projects are divided into modules. We use es6 import/export syntax.
To create a module, create a .tsx
file and at the end of the file, on one line, export the item of interest as the default export.
...
export default MyComponent;
The file name must match the name of the default export. This helps with consistency and helps with automatic documentation.
Default export name must match file name// Chart/index.tsx
export default MyComponent;
// MyButton.tsx
export default MyButton;
If an item takes up more than one line, define it above and then export it on one line.
Export must be on a single lineconst MyCar = {
...
};
export default MyCar;
When importing a module, leave out the extension if it's a .ts
or .tsx
file.
import MyComponent from '../shared/MyComponent';
If a module is a folder, leave off index.tsx
when importing.
// File structure
MyComponent/
index.tsx
style.scss
MyButton.tsx
// Bad:
import MyComponent from './MyComponent/index';
// Good:
import MyComponent from './MyComponent';
number
- all number types (int, float, etc.)string
- text of any lengthboolean
- either true or falseundefined
- unsetnull
- no valuevoid
- eitherundefined
ornull
- but we refrain from using it except for with function return types
Array
- array of elementsMap
- dictionary with keys and valuesSet
- set where items can only exist once in the listObject
- unordered map where keys must be strings
Type/Interface
- a data type/interfaceEnum
- an enum (does not support dynamic keys or values that are objects)
const a = 5 < 10;
const b = 'Hanalei';
const c = [1, 7, 5];
const d = 27;
const e = { name: 'Gabe' };
const f = e.age;
Example Result
const a = 5 < 10; // boolean const b = 'Hanalei'; // string const c = [1, 7, 5]; // number[] const d = 27; // number const e = { name: 'Gabe' }; // object, { [k: string]: string } const f = e.age; // undefined
Use const
when defining variables. Only use let
if absolutely necessary (a variable's value changes). It is unforgivable to use var
.
// Good
const name = 'Divardo';
// Good
const getName = () => { ... };
// Good only because age is modified later
let age = 10;
...
age += 1;
Object property modifications do not count as re-assigning:
// Still use const
const user = {
name: 'Divardo',
age: 10,
};
// These do not modify user
user.name = 'Jane';
user.age += 1;
An easy way to choose between const
and let
is simply to always start with const
and only change it to let
if the editor complains.
/**
* Format a last name to indicate ownership (e.g. Calicci becomes Calicci's and Abrams becomes Abrams')
* @author Gabe Abrams
* @param lastName the last name of the person
* @returns last name in ownership form
*/
____ genOwnershipForm = (lastName: any) => {
// Last name that ends with "s" gets an apostrophe only
if (lastName.endsWith('s')) {
return `${lastName}'`;
}
// Other last names get "'s" suffix
return `${lastName}'s`;
};
// Print account status
____ lastName = 'Calicci';
console.log(`This is ${genOwnershipForm(lastName)} laptop.`);
// Bonus points for finding other errors in the code
Example Result
/** * Format a last name to indicate ownership (e.g. Calicci becomes Calicci's and Abrams becomes Abrams') * @author Gabe Abrams * @param lastName the last name of the person * @returns last name in ownership form */ const genOwnershipForm = (lastName: string) => { // Last name that ends with "s" gets an apostrophe only if (lastName.endsWith('s')) { return `${lastName}'`; }// Other last names get "'s" suffix return
${lastName}'s
; };// Print account status const lastName = 'Calicci'; console.log(
This is ${genOwnershipForm(lastName)} laptop.
);
// Create a bank account
____ checking: BankAccount = {
firstName: 'Gabe',
lastName: 'Abrams',
balance: 29,
};
// Deduct money from the account balance
checking.balance -= 10;
Example Result
// Create a bank account const checking: BankAccount = { firstName: 'Gabe', lastName: 'Abrams', balance: 29, };// Deduct money from the account balance checking.balance -= 10;
We favor longer variable names that are descriptive: mouseX
is better than x
. When apps are bundled and built, variable names are minified anyway. Don't minimize number of chars, minimize confusion and ambiguity.
// Variable:
favoriteFruits
// Constant:
FAVORITE_FRUITS // (all caps with underscores)
// Enum:
FavoriteFruit // (notice this is not pluralized)
// Typescript Class (not a css class):
FavoriteFruits
// Component:
FavoriteFruits
// CSS class:
.FavoriteFruits-container
If a variable is an optional boolean, always name it such that false
is the default value. For example, if users are usually online while using the tool, an optional status boolean should be named isOffline
so that false
can be the default value (instead of isOnline
where true
is the default value). You'll really appreciate this consistency farther down the line and when reading other people's code. This is especially useful in React with optional boolean component props which are only added if the boolean is true (if the default is false, it saves a lot of useless extra code).
// If users are usually teachers...
// Bad:
const isNotStudent?: boolean = ...
// Good
const isStudent?: boolean = ...
// NOTE: This class represents a user in the database
class customUser {
...
}
// NOTE: This css class is used in the component called "MyButton"
.button-label {
...
}
// NOTE: Comments are usually not included
let noComments = true;
// NOTE: User age is usually included
let userAgeKnown = false;
Example Result
// NOTE: This class represents a user in the database class CustomUser { ... }// NOTE: This css class is used in the component called "MyButton" .MyButton-label { ... }
// NOTE: Comments are usually not included let commentsIncluded = true;
// NOTE: User age is usually included let userAgeUnknown = false;
All variables must have types. If typescript cannot infer a type, one must be explicitly defined.
All variables must have specific types (if not defined or inferred)const name: string = getName();
// Note: always include a space before the type
All numbers must include units, either in the variable name itself or in a comment at the variable's declaration:
All numbers must include units// Good:
const heightFt = 5;
// or
const height = 5; // ft
// Bad:
const height = 5;
If you have the option to choose units, here are our preferred set of units:
- timestamp/time = ms
- age = years
- date = ms since epoch (number)
- video timestamp = seconds since start of video
// NOTE: This is the time since the user was last active
const lastActive = ...;
// NOTE: This is the time to wait before playing the next video
const videoDelay = 10;
// NOTE: This is the current "feels like" temperature outside
const feelsLike = 74;
Example Result
// NOTE: This is the time since the user was last active const lastActiveTimestamp = ...;// NOTE: This is the time to wait before playing the next video const videoDelaySec = 10;
// NOTE: This is the current "feels like" temperature outside const feelsLikeDeg = 74; // Fahrenheit
We reserve single quotes for typescript and double quotes for JSX. This makes our code differentiable and nestable.
Only use single quotes in typescript, reserve double quotes for jsx props// Import other components
import toggleButton from 'toggle-button';
const name = 'Divardo';
Arrays that start off empty must have a declared type.
Arrays must have declared typesconst names: string[] = [];
Objects must be defined with proper spacing to preserve clarity.
Space out object properties// Good:
const user = { name: 'Divardo' };
// Bad:
const user = {name: 'Divardo'};
If adding a variable to an object with the same name, use shorthand.
Used object property shorthandconst name = 'Divardo';
const age = 10;
// Good:
const user = { name, age };
// Bad:
const user = { name: name, age: age };
Always use ===
for equality. If you're using ==
there had better be a really good reason.
if (name === 'Divardo') {
...
}
Triple equals (===
) compares type and value. Thus, '5' === 5
is false.
If you want to compare just value and not type, convert the variables first: Number.parseInt('5', 10) === 5
, which will now return true.
If you want to do a deep comparison of two objects, use a library.
Math must be readable and grouped. Only one operation can happen in each parentheses group.
Wrap each mathematical operation in parenthesesconst x = (((a + b) * (c + d)) / e);
It turns out few people understand the subtleties of num++
or num--
, so we do not use those operators except in for loops, where its use is well-understood.
// Bad:
age++;
// Good:
age += 1;
This works nicely with other mathematical operations:
- Add:
age += 2
- Multiply:
age *= 3
- Divide:
age /= 5
- Subtract:
age -= 1
// Get the restaurant hours
const hours = await getHoursFromDB();
// Create restaurant info object
const restaurantInfo = {
name: 'Dosa House',
hours: hours,
};
// Create the order
const foodOrder = { timestampMs: 12093840982,
cost: 12.78,
restaurantInfo: restaurantInfo,
};
// Ask the user for a tip
const tip = await askUserForTip();
// Update the cost to include the tip
foodOrder.cost = (foodOrder.cost * (tip + 1));
// Add a dollar to the cost as a kitchen surcharge
foodOrder.cost++;
Example Result
// Get the restaurant hours const hours = await getHoursFromDB();// Create restaurant info object const restaurantInfo = { name: 'Dosa House', hours, };
// Create the order const foodOrder = { timestampMs: 12093840982, cost: 12.78, // USD restaurantInfo, };
// Ask the user for a tip const tip = await askUserForTip(); // percent
// Update the cost to include the tip foodOrder.cost *= (tip + 1);
// Add a dollar to the cost as a kitchen surcharge foodOrder.cost += 1;
String addition must always be done using template strings.
Only use template strings for string additionconst weather = `The temperature is ${temp} today`;
Never nest ternaries and always wrap them in parentheses, even if they're short.
Never nest ternaries// Bad:
const beingType = (age > 150 ? (allergy === 'sun' ? 'vampire' : 'zombie') : 'human');
// Good:
const monsterType = (allergy === 'sun' ? 'vampire' : 'zombie');
const beingType = (age > 150 ? monsterType : 'human');
// Bad
const carStatus = (miles > getCarMiles() ? `${make} is older than ${standard}` : 'new');
// Good
const carStatus = (
miles > getCarMiles()
? `${make} is older than ${standard}`
: 'new'
);
// Bad
const carStatus = miles > getCarMiles()
? `${make} is older than ${standard}`
: 'new'
;
// Bad
const carStatus = (miles > getCarMiles()
? `${make} is older than ${standard}`
: 'new'
);
// Good
const carStatus = (
miles > getCarMiles()
? `${make} is older than ${standard}`
: 'new'
);
// Bad:
const carStatus = (
miles > getCarMiles() ?
`${make} is older than ${standard}` :
'new'
);
// Good
const carStatus = (
miles > getCarMiles()
? `${make} is older than ${standard}`
: 'new'
);
// Bad:
const isInClass = (
joinedZoom ||
sittingInPerson ||
watchedVideo
);
// Good:
const isInClass = (
joinedZoom
|| sittingInPerson
|| watchedVideo
);
// Bad:
const age = (
currentAge +
elapsedTime
);
// Good:
const age = (
currentAge
+ elapsedTime
);
const isStudent = (
inRoster && (!isTTM ||
(studentEnrollments +
observerEnrollments) >
0
)
);
Example Result
// Required formatting: const isStudent = ( inRoster && ( !isTTM || ( (studentEnrollments + observerEnrollments) > 0 ) ) );// It's also okay to put enrollments on their own lines const isStudent = ( inRoster && ( !isTTM || ( ( studentEnrollments + observerEnrollments ) > 0 ) ) );
// Much better with comments: const isStudent = ( // The user must be in the roster inRoster // ...and cannot be a TTM && ( // The user is not a TTM !isTTM // ...or the user is a student/auditor || ( (studentEnrollments + observerEnrollments) > 0 ) ) );
When retrieving values from objects or tuples (arrays of length 2), you can destructure. We prefer that you do not alias (do not rename destructured values). This helps other programmers follow data around your code and also helps when people use find
or other searching mechanisms.
const {
name,
age,
} = person;
We always use try/catch
to handle errors (we don't use .then
or .catch
unless absolutely necessary).
Errors must always contain two important ingredients:
- A human-readable message that explains the issue in plain and simple english without revealing any technical details or inner workings of the app.
- An error code in the form
ABC123
, where the letters are a code that's specific to the project or section of the project, and the number is a unique number assigned to the error.
To make this possible, we use a custom error called ErrorWithCode
which can be found in dce-reactkit
.
We never use the function
keyword. It's been outdated for years.
// Banned:
function honk(horn) {
...
}
Use arrow functions as much as possible: this ensures appropriate context binding and reduces complexity.
Use arrow functions as much as possible// Helper
const honk = (horn: HondaHorn) => {
...
};
// Inline
cars.forEach((car) => {
...
});
To keep our arrow functions uniform, we try not to use shorthand unless it's very helpful. Thus, parentheses around arguments are usually required and curly brackets are usually required surrounding the body, even if the function is very simple. Similarly, most arrow functions should be multiline.
Arrow functions usually have ( ) and { } and are usually multiline// Banned:
(x) => x + 1
// Banned:
name => { print(name); }
// Good:
(x) => {
return x + 1;
}
// Good:
(name) => {
print(name);
}
// NOTE: created by Chat GPT using this prompt:
// Create a typescript function that takes a string and counts the number of sentences
function countSentences(text: string): number {
// Define an array of characters that may end a sentence.
const sentenceEnders = [".", "!", "?"];
// Initialize the sentence count to zero.
let sentenceCount = 0;
// Loop through each character in the text.
for (let i = 0; i < text.length; i++) {
const char = text[i];
// If the character is a sentence ender, increment the sentence count.
if (sentenceEnders.includes(char)) {
sentenceCount++;
}
}
// Return the sentence count.
return sentenceCount;
}
// Bonus points for optimizing the code too!
Example Result
// NOTE: moved this outside of function so it isn't initialized over and over // Define an array of characters that may end a sentence. const sentenceEnders = ['.', '!', '?'];const countSentences = (text: string): number => { // Initialize the sentence count to zero. let sentenceCount = 0;
// Loop through each character in the text. for (let i = 0; i < text.length; i++) { const char = text[i];
// If the character is a sentence ender, increment the sentence count. if (sentenceEnders.includes(char)) { sentenceCount <b>+=</b> 1; }
}
// Return the sentence count. return sentenceCount; }
All named functions absolutely must have JSDoc definitions. Include a description of the purpose of the function, add one or more author tags for people who worked on that function, and describe arguments and return.
Add JSDoc to all named functions/**
* Prepares a car to race: fills up the tank and tests the engine
* @author Your Name
* @param car the car to prepare
* @param raceType the type of race to prepare for
* @returns cost of the preparation process in USD
*/
const prepareCar = async (car: Car, raceType: RaceType): number => {
...
};
Notice that data types are included inline in the typescript function declaration. To minimize documentation update issues, keep data types and sync/async status inline in the typescript. Do not include them in JSDoc. Thus, {type}
tags and the @async
keyword are not allowed
// Bad:
/**
* Prepares a car to race: fills up the tank and tests the engine
* @author Your Name
* @async
* @param {Car} car the car to prepare
* @param {RaceType} raceType the type of race to prepare for
* @returns cost of the preparation process in USD
*/
const prepareCar = async (car: Car, raceType: RaceType): number => {
...
};
// Good:
/**
* Prepares a car to race: fills up the tank and tests the engine
* @author Your Name
* @param car the car to prepare
* @param raceType the type of race to prepare for
* @returns cost of the preparation process in USD
*/
const prepareCar = async (car: Car, raceType: RaceType): number => {
...
};
const countSentences = (
opts: {
text: string,
sentenceEnders?: string[],
countEmptyLines?: boolean,
},
): number {
...
};
Example Result
/** * Count the number of sentences in a string * @author Gabe Abrams * @param opts object containing all arguments * @param opts.text the text to analyze * @param [opts.sentenceEnders] custom sentence ender punctuation marks * to use when counting sentences * @param [opts.countEmptyLines] if true, count empty lines as sentences too * @returns the number of sentences in the text */ const countSentences = ( opts: { text: string, sentenceEnders?: string[], countEmptyLines?: boolean, }, ): number { ... };
To include a default value for an argument, do this:
const helper = (a: string, b: string = 'default value') => {
// ...
};
// Bad:
const helper = (text: string, greeting: string = 'hi', id: number) => {
...
};
// Good:
const helper = (text: string, id: number, greeting: string = 'hi') => {
...
};
With types and defaults, arguments can take up a lot of space. As usual, if one item is on another line, all items must be on their own lines. Here's how to do this with arguments:
const helper = (
opts: {
requiredParam1: string,
requiredParam2: number,
optionalParam: string = 'default value',
},
) => {
// ...
};
Ask yourself: should this code be a helper function or a class? One rule of thumb is that if code will only be instantiated once, it's probably best as a helper function. Also, if it doesn't have to maintain its own context (it's mostly data, maybe a couple helper functions that do not refer to "this") it could probably just be a type.
All variables in the class must be defined at the top of the class (all this.xyz
variable).
class User {
private name: string;
private age: number;
private pronouns?: string;
private gender?: string;
...
}
Every property and methods must be labeled as either private
or public
. We require that you make an active decision on privacy. Note: #
is an acceptable substitute for private
if you prefer.
class User {
// Bad:
name: string;
// Good:
private name: string;
// Bad:
getName(): string {
...
}
// Good:
public getName(): string {
...
}
}
If a method doesn't refer to this
, then there's no reason it can't be a static
method. Thus, if a method doesn't refer to this
, we require that it be static
. This helps because it's easy for others to figure out if a function depends on class variables.
class User {
// Bad:
public sayHello() {
console.log('Hello!');
}
// Good:
public static sayHello() {
console.log('Hello!');
}
// Good:
public getName(): string {
return this.name;
}
}
// Example:
class Kayak {
// Number of seats in the kayak
private numSeats: number;
// Current location
private location: { x: number, y: number };
// Current direction
private direction: number; // degrees
/**
* Performs paddle operation, moving the kayak either forward or backward
* @author Gabe Abrams
* @param [backward] if true, paddle backward
*/
public paddle(backward?: boolean) {
// TODO: implement
}
/**
* Either turns the kayak left (ccw) or right (cw)
* @author Gabe Abrams
* @param [opts] object containing all arguments
* @param [opts.direction=left] the direction to turn
* @param [opts.angle=45] number of degrees to turn the kayak
*/
public turn(
opts: {
direction?: 'left' | 'right',
angle?: number,
} = {},
) {
// TODO: implement
}
}
We use Type
to define types (instead of Interface
) because of some important advanced features that we leverage in React. Note that many people disagree with Gabe on this, so be flexible in interviews, be ready to use Interface
as well.
type Car = {
...
};
We treat types as objects, ending lines with ,
instead of ;
. This isn't because of very important functionality, this is just for consistency. The commas show that each item is part of the whole.
type Car = {
// Company that the car was made by
make: string,
// Age
age: number, // years
};
Every property in a type must be accompanied by a full description and units (as usual). Comment above each item.
Describe all type properties// Good:
type Car = {
// Company that the car was made by
make: string,
// Age of the car
age: number, // years
};
// Bad:
type Car = {
make: string,
age: number,
};
// Example: Samosa Chaat
type SamosaChaat = {
// Filling inside of the samosa
filling: string,
// If true, the chaat will be prepared with more spice
spicy: boolean,
// Number of samosas in the chaat
samosaQuantity: number,
};
If a type can have multiple different structures, use |
:
type Vehicle = (
| {
// Type of vehicle
type: 'car',
// Number of wheels
numWheels: number,
// True if car fits in a compact parking spot
isCompact: boolean,
}
| {
// Type of vehicle
type: 'truck',
// Number of wheels
numWheels: number,
// True if truck has a trailer hitch
hasTrailerHitch: boolean,
}
);
In the case where all forms of a type have shared parts, separate that out into a general section of the type:
type Vehicle = (
// Common parameters
{
// Number of wheels
numWheels: number,
}
// Type-dependent parameters
& (
// Car
| {
// Type of vehicle
type: 'car',
// True if car fits in a compact parking spot
isCompact: boolean,
}
// Truck
| {
// Type of vehicle
type: 'truck',
// True if truck has a trailer hitch
hasTrailerHitch: boolean,
}
)
);
// Example: Samosa Chaat with Optional Drink
type SamosaChaat = (
{
// Filling inside of the samosa
filling: string,
// If true, the chaat will be prepared with more spice
spicy: boolean,
// Number of samosas in the chaat
samosaQuantity: number,
} & (
// No side drink
| {
// If true, a side is included
hasSide: false,
}
// Includes side
| {
// If true, a side is included
hasSide: true,
// Description of side
side: {
// Name of the side
name: (
| 'Lassi'
| 'Water'
| 'Chai'
),
// Additional cost of the side
costDollars: number,
},
}
)
);
For your reference, here's a types cheatsheet with props that I use frequently:
// Primitives
string // text or character (e.g. 'Hello' or 'h')
number // integer or float (e.g. 4 or 4.73)
boolean // true or false
undefined // Unset value
null // Absence of value
// Operations
(string | number) // Any type in this list
('blue' | 'red' | 'green') // Any value in a list
// Arrays
string[] // Array of strings
number[] // Array of numbers
Car[] // Array containing objects of type Car
(string | Car)[] // Array of strings or objects of type Car
// Enums
EnumName // Enums also function as types
// Common Data Structures
{ [k: string]: number } // Object where keys are strings, values are numbers
Map<string, Car> // Map where keys are strings, values are objects of type Car
Set<Car> // Set of objects of type Car
// Functions
() => undefined // Function with no arguments, no return
(car: Car) => undefined // Function with an argument and no return value
(car: Car, year?: number) => number // Function with optional arg and return type
// Object with specific structure
{
name: string,
age?: number,
}
// React types
React.ReactNode // Renderable React content
React.FC<Props> // Functional Component
// Types to avoid
any
unknown
Avoid casting at all costs, but if it must be done, use the as
keyword:
const user = (await fetchUserFromAPI()) as User;
Casting is different from value conversion:
// Convert to a string:
const str = String(value);
// Convert to a number:
const int = Number.parseInt(value, 10);
const fl = Number.parseFloat(value);
// Convert to a boolean:
const b = !!value;
If a value can take on many pre-determined values, we use enums. However, we understand that enums are limited: keys are not dynamic and values cannot be complex objects. Use enums where possible.
Prefer enums, but substitute with objects if not possibleenum Instrument {
// An upright piano
Piano = 'Piano',
// A violin string instrument
Violin = 'Violin',
}
There are shorthands for numerical enums. To ensure readable database entries and debugging, we prefer string-valued enums where the value is identical to the name.
Keys and values of enums should be identical strings// Bad:
enum Instrument {
Piano = 'piano',
Violin = 'violin',
}
// Bad:
enum Instrument {
Piano,
Violin,
}
// Bad:
enum Instrument {
Piano = 1,
Violin = 2,
}
// Good:
enum Instrument {
// An upright piano
Piano = 'Piano',
// A violin string instrument
Violin = 'Violin',
}
// Type of fillings
enum Filling {
// Potato and pea filling
PotatoPea = 'PotatoPea',
// Cauliflower mash filling
Cauliflower = 'Cauliflower',
}
// Name of side
enum SideName {
// Mango or salty lassi
Lassi = 'Lassi',
// Iced water
Water = 'Water',
// Masala tea
Chai = 'Chai',
}
// Example: Samosa Chaat with Optional Drink
type SamosaChaat = (
{
// Filling inside of the samosa
filling: Filling,
// If true, the chaat will be prepared with more spice
spicy: boolean,
// Number of samosas in the chaat
samosaQuantity: number,
} & (
// No side drink
| {
// If true, a side is included
hasSide: false,
}
// Includes side
| {
// If true, a side is included
hasSide: true,
// Description of side
side: {
// Name of the side
name: SideName,
// Additional cost of the side
costDollars: number,
},
}
)
);
It's important to know the type of item, not just the item itself, especially when reading other people's code. Also, some enums may have duplicate keys. This is why we don't destructure enums.
Never destructure enumsTo keep code readable and simple, if there are ever more than three of anything (arguments, values, anything), each must be on its own line. Honestly, it's okay to put items on their own lines if there are two or more items.
If more than three elements, put each element on its own lineAdditionally, whenever there is one item on its own line, all other items must be on their own lines as well.
Arrays:
// Bad: first item is not on its own line
const fruits = ['Apple',
'Orange',
'Pineapple',
'Mango',
];
// Bad: last item is not on its own line
const fruits = [
'Apple',
'Orange',
'Pineapple',
'Mango'];
// Good
const fruits = [
'Apple',
'Orange',
'Pineapple',
'Mango',
];
Function calls:
// Bad: first item is not on its own line
const firstName = database.initialize()
.getUser()
.firstName;
// Bad
const firstName = (
database.initialize()
.getUser()
.firstName
);
// Good
const firstName = (
database
.initialize()
.getUser()
.firstName
);
Arguments:
// Bad: first item is not on its own line
startCar(Engine.getFourCylinder(),
WheelKit.manufacture(4),
Horn.prepare(),
);
// Bad: last item is not on its own line
startCar(Engine.getFourCylinder(),
WheelKit.manufacture(4),
Horn.prepare());
// Good
startCar(
Engine.getFourCylinder(),
WheelKit.manufacture(4),
Horn.prepare(),
);
// Bit 1:
const output = getOutput(text, {
parse: true,
delimiter: ',',
});
// Bit 2:
const ageNextYear = (
ageAtLogin
+ elapsedYears
+ 1);
// Bit 3:
const studentLists = [getDCEStudents(),
getFASUsers().roster
.filter((user) => {
return user.isStudent;
}).map((user) => {
return user.userInfo;
}),
];
Example Result
// Bit 1: const output = getOutput( text, { parse: true, delimiter: ',', }, );// Bit 2: const ageNextYear = ( ageAtLogin + elapsedYears + 1 );
// Bit 3: const studentLists = [ getDCEStudents(), getFASUsers() .roster .filter((user) => { return user.isStudent; }) .map((user) => { return user.userInfo; }), ];
For all arrays, function arguments, object definitions, and other comma-delineated objects, use trailing commas. This helps create clean git diffs and reduces typos.
Use trailing commasSee the examples above. Look for trailing commas.
Note: this is not supported in `.json` files. Thus, when possible, we prefer `.tsx` for data if possible.
// Bad:
const showTeacherInfo = () => {
// Make sure there's at least one enrolled user
if (enrollments.length > 0) {
// Print
console.log('This course has at least one enrolled user');
// Check if the course has a teacher
const hasTeacher = enrollments.some((enrollment) => {
return (enrollment.type === 'TTM');
});
if (hasTeacher) {
// Print
console.log('This course already has an assigned teacher');
// Update the UI
dispatch({
type: ActionType.ShowTeacherInfo,
});
}
}
};
// Good:
const showTeacherInfo = () => {
// Make sure there's at least one enrolled user
if (enrollments.length === 0) {
return;
}
// Print
console.log('This course has at least one enrolled user');
// Check if the course has a teacher
const hasTeacher = enrollments.some((enrollment) => {
return (enrollment.type === 'TTM');
});
if (!hasTeacher) {
return;
}
// Print
console.log('This course already has an assigned teacher');
// Update the UI
dispatch({
type: ActionType.ShowTeacherInfo,
});
};
Guard clauses are if
statements that terminate execution. Using guard clauses removes the need for nesting.
Array functions are awesome. Replace loops with array functions wherever possible. Unfortunately, array functions do not fully support async/await yet. That's the only time we must use a for
loop.
// Good:
fruits.forEach((fruit: Fruit) => {
console.log(`I love ${fruit}s`);
});
// Bad:
fruits.forEach(async (fruit: Fruit) => {
await fruit.waitToRipen();
});
// Good
for (let i = 0; i < fruits.length; i++) {
await fruits[i].waitToRipen();
}
Each array function takes an anonymous "operation function" that is called once for each element in the array. Each time the operation function is called, it receives one of the array elements as an argument. This occurs sequentially such that the operation function is called with the first element, then the second element, then the third, and so on.
The operation function holds the code that you want to run for each element.
const fruits = ['apple', 'orange', 'pineapple'];
fruits.forEach((fruit: string) => {
console.log(`I love ${fruit}s`);
});
// Output:
// > I love apples
// > I love oranges
// > I love pineapples
You can also get the element's index:
fruits.forEach((fruit, i) => {
// ...
});
const studentNames = ['Divardo', 'Calicci', 'Kai', 'Manu', 'Anini', 'Alli'];
// TODO: implement
// Output:
// > Hello, Calicci
// > Hello, Manu
// > Hello, Alli
Example Result
const studentNames = ['Divardo', 'Calicci', 'Kai', 'Manu', 'Anini', 'Alli'];// Greet every other student studentNames.forEach((studentName, i) => { // Skip even indexed students if (i % 2 === 0) { return; }
// Greet the student console.log(
Hello, ${studentName}
); });
The operation function takes an element of the original array, does some computation, and then returns the corresponding element for the new array.
type Car = {
// The color of the car
color: string,
// The year the car was made
year: number,
};
const cars: Car[] = [
{
color: 'red',
year: 2015,
},
{
color: 'blue',
year: 2018,
},
];
const years: number[] = cars.map((car: Car) => {
return car.year;
});
const olderCars: Car[] = cars.map((car: Car) => {
return {
...car,
year: car.year - 1,
};
});
const studentNames = ['Divardo', 'Calicci', 'Kai'];
// TODO: implement
// Output: ['Legal Name: Divardo', 'Legal Name: Calicci', 'Legal Name: Kai']
Example Result
const studentNames = ['Divardo', 'Calicci', 'Kai'];// Prepend each student's name const prependedStudentNames = studentNames.map((studentName) => { // Add prefix return
Legal Name: ${studentName}
; });
// Bad:
const olderCars: Car[] = cars.map((car: Car) => {
car.year -= 1;
return car;
});
// Good:
const olderCars: Car[] = cars.map((car: Car) => {
return {
...car,
year: car.year - 1,
};
});
The operation function takes an element in the array and returns true/truthy if this element passes the test. If any element results in the operation function returning true/truthy, some
immediately returns true. If no elements result in the operation function returning true/truthy, some
returns false after going through the entire array.
type Car = {
// The color of the car
color: string,
// The year the car was made
year: number,
};
const cars: Car[] = [
{
color: 'red',
year: 2015,
},
{
color: 'blue',
year: 2018,
},
];
const atLeastOneRed = cars.some((car: Car) => {
return (car.color === 'red');
});
const listA = ['Divardo', 'Calicci', 'Kai'];
const listB = ['Max', 'Clark', 'Ash'];
// TODO: implement
// Output:
// > true
// > false
Example Result
const listA = ['Divardo', 'Calicci', 'Kai']; const listB = ['Max', 'Clark', 'Ash'];// Create a list of vowels const vowels = 'aeiou'.split('');
// Go through each list and check if a student's name ends with a vowel [listA,listB].forEach((studentNames) => { const atLeastOneNameEndsWithVowel = studentNames.some((studentName) => { // Get last letter const lastLetter = ( studentName .toLowerCase() .substring(studentName.length - 1) );
// Check if last letter is a vowel return vowels.includes(lastLetter);
}); });
The operation function takes an element in the array and returns true/truthy if this element passes the test. If all elements results in the operation function returning true/truthy, every
returns true after going through the entire array. If at any point, one of the elements result in the operation function returning false/falsy, every
immediately returns false.
type Car = {
// The color of the car
color: string,
// The year the car was made
year: number,
};
const cars: Car[] = [
{
color: 'red',
year: 2015,
},
{
color: 'blue',
year: 2018,
},
];
const allCarsAreRed = cars.every((car: Car) => {
return (car.color === 'red');
});
type Student = {
// First name
name: string,
// Age
age: number, // years
};
const listA: Student[] = [
{ name: 'Divardo', age: 17 },
{ name: 'Calicci', age: 18 },
{ name: 'Kai', age: 19 },
];
const listB: Student[] = [
{ name: 'Max', age: 20 },
{ name: 'Clark', age: 18 },
{ name: 'Ash', age: 22 },
];
// TODO: implement
// Output:
// > false
// > true
Example Result
const listA = [ { name: 'Divardo', age: 17 }, { name: 'Calicci', age: 18 }, { name: 'Kai', age: 19 }, ]; const listB = [ { name: 'Max', age: 20 }, { name: 'Clark', age: 18 }, { name: 'Ash', age: 22 }, ];// Go through each list and check if all students are 18+ [listA,listB].forEach((students) => { const allStudentsAtLeast18 = students.every((student) => { return (student.age >= 18); }); });
The operation function takes an element in the array and returns true/truthy if this element passes the test. At the end, filter
returns a new array that contains only the elements that pass the test.
type Car = {
// The color of the car
color: string,
// The year the car was made
year: number,
};
const cars: Car[] = [
{
color: 'red',
year: 2015,
},
{
color: 'blue',
year: 2018,
},
];
const redCars = cars.filter((car: Car) => {
return (car.color === 'red');
});
type Student = {
// First name
name: string,
// Age
age: number, // years
};
const students: Student[] = [
{ name: 'Divardo', age: 17 },
{ name: 'Calicci', age: 18 },
{ name: 'Kai', age: 19 },
{ name: 'Max', age: 20 },
{ name: 'Clark', age: 18 },
{ name: 'Ash', age: 22 },
{ name: 'Anna', age: 12 },
];
// TODO: implement
// Output:
// [
// { name: 'Divardo', age: 17 },
// { name: 'Calicci', age: 18 },
// { name: 'Kai', age: 19 },
// { name: 'Clark', age: 18 },
// ]
Example Result
const students = [ { name: 'Divardo', age: 17 }, { name: 'Calicci', age: 18 }, { name: 'Kai', age: 19 }, { name: 'Max', age: 20 }, { name: 'Clark', age: 18 }, { name: 'Ash', age: 22 }, { name: 'Anna', age: 12 }, ];// Filter to just teen students const teens = students.filter((student) => { return ( // Old enough student.age >= 13 // ...and not too old && student.age < 20 ); });
The operation function takes an element in the array and returns true/truthy if this element passes the test. At the end, find
return the first element that passes the test. If no elements pass the test, find
returns undefined
.
type Car = {
// Color of the body of the car
color: string,
// Year that the car was manufactured
year: number, // e.g. 1995
// The first name of the owner of the car
owner: string,
};
const cars: Car[] = [
{
color: 'red',
year: 2015,
owner: 'Gabe',
},
{
color: 'blue',
year: 2018,
owner: 'Ben',
},
];
const bensCar = cars.find((car: Car) => {
return (car.owner === 'Ben');
});
type Student = {
// First name
name: string,
// Age
age: number, // years
};
const students: Student[] = [
{ name: 'Divardo', age: 17 },
{ name: 'Calicci', age: 18 },
{ name: 'Kai', age: 19 },
{ name: 'Max', age: 20 },
{ name: 'Clark', age: 18 },
{ name: 'Ash', age: 22 },
{ name: 'Anna', age: 12 },
];
// TODO: implement
// Output:
// { name: 'Ash', age: 22 }
Example Result
const students = [ { name: 'Divardo', age: 17 }, { name: 'Calicci', age: 18 }, { name: 'Kai', age: 19 }, { name: 'Max', age: 20 }, { name: 'Clark', age: 18 }, { name: 'Ash', age: 22 }, { name: 'Anna', age: 12 }, ];// Find a student who's name starts with 'A' const studentWithNameStartingInA = students.find((student) => { return student.name.startsWith('A'); });
Object functions are great for iterating through objects. Use them whenever possible. But just like array functions, if using async/await inside the loop, you'll need a for loop.
Use object functions whenever possible unless await is used insideconst idToName = {
12459: 'Divardo',
50829: 'Calicci',
50628: 'Keala',
};
const ids = Object.keys(idToName);
Note: no matter what type of keys you use, Object.keys
returns an array of strings. In the example above, ids
is a string array with values ['12459', '50829', '50628']
.
const idToName = {
12459: 'Divardo',
50829: 'Calicci',
51628: 'Keala',
};
const names = Object.values(idToName);
It's often important to loop through an enum or object's keys or values.
Object.values(idToName).forEach((name: string) => {
console.log(`Hello, ${name}!`);
});
const idToName = {
12459: 'Divardo',
50829: 'Calicci',
51628: 'Keala',
};
// TODO: implement
// Output:
// [12, 50, 50]
Example Result
const idToName = { 12459: 'Divardo', 50829: 'Calicci', 51628: 'Keala', };// Extract the first two letters of each id number const idPrefixes = Object.keys(idToName).map((id) => { return id.substring(0, 2); });
Typescript is a single-threaded interpreted language and is much slower than C/C++ and other compiled languages. But, asynchronous code runs really fast and is great for servers and good for simple clients like browsers. Thus, Express and React are great choices as long as we use asynchronous code when necessary.
This function is non-blocking and executes its function body in the background.
const funcName = async () => {
...
};
For asynchronous functions that have return values, simply wrap the return type in Promise<...>
:
// For example, here's an async function that returns a number
const funcName = async (): Promise<number> => {
...
};
Always use the async
keyword instead of returning Promise
objects. In fact, refrain from referencing Promise
except in types and when using Promise.all
.
// Bad:
const ready = new Promise((resolve, reject) => {
...
});
// Bad:
loadFile.then((contents) => {
// ...
});
// (where loadFile returns a promise)
// Bad:
const funcName = () => {
...
const p = new Promise((resolve, reject) => {
...
});
...
return p;
};
// Okay:
const funcName = async (): Promise<number> => {
...
await doSomething();
...
return 5;
};
// Okay:
await Promise.all(tasks);
// Import caccl-api
import initAPI from 'caccl-api';
// Initialize the API
const api = initAPI({
// TODO: fill this in
});
// TODO: create a function "createTestAssignment" that creates a unique assignment in the course and returns it
// NOTE: check out the caccl-api docs at bit.ly/caccl-api
Example Result
// Import caccl-api import initAPI from 'caccl-api';// Initialize the API const api = initAPI({ canvasHost: 'canvas.harvard.edu', accessToken: '1895~sdjfoa9me098fjoiasnudo8f7am9sod8ufnoaisdunfkuasdf', defaultCourseId: 53450, });
/** * Create a unique test assignment within the sandbox course * @author Gabe Abrams * @returns assignment object that was created */ const createTestAssignment = async () => { // Generate a unique assignment name const uniqueAssignmentName =
Test Assignment[${Date.now()}-${Math.random()}]
;// Create an assignment const assignment = await api.course.assignment.create({ name: uniqueAssignmentName, submissionTypes: ['none'], published: true, });
// Return the new assignment return assignment; };
To wait for an async task, always use await
instead of .then
.
// Bad:
fruit.ripe().then(() => {
console.log('The fruit is ripe!');
});
// Good:
await fruit.ripe();
To catch an error from an awaited task, just surround with try/catch
instead of .catch
. This is because if you forget to use .catch
, errors disappear into the ether.
// Bad:
fruit.ripe().catch((err) => {
...
});
// Good:
try {
await fruit.ripe();
} catch (err) {
...
}
Use the await
flag to wait for each function to finish.
const data1 = await doTask1();
const data2 = await doTask2();
// Import caccl-api
import initAPI from 'caccl-api';
// Initialize the API
const api = initAPI({
// TODO: fill this in
});
// TODO: implement the solution
// NOTE: check out the caccl-api docs at bit.ly/caccl-api
Example Result
// Import caccl-api import initAPI from 'caccl-api';// Initialize the API const api = initAPI({ canvasHost: 'canvas.harvard.edu', accessToken: '1895~sdjfoa9me098fjoiasnudo8f7am9sod8ufnoaisdunfkuasdf', defaultCourseId: 53450, });
/** * Create a unique test assignment within the sandbox course * @author Gabe Abrams * @returns assignment object that was created */ const createTestAssignment = async () => { // Generate a unique assignment name const uniqueAssignmentName =
Test Assignment [${Date.now()}-${Math.random()}]
;// Create an assignment const assignment = await api.course.assignment.create({ name: uniqueAssignmentName, submissionTypes: ['none'], published: true, });
// Return the new assignment return assignment; };
// First, create an assignment await createTestAssignment();
// Then, get the full list of assignments const assignments = await api.course.assignment.list();
Combine await
with Promise.all
for parallel execution.
const [
data1,
data2,
] = await Promise.all([
doTask1(),
doTask2(),
]);
// Import caccl-api
import initAPI from 'caccl-api';
// Initialize the API
const api = initAPI({
// TODO: fill this in
});
// TODO: implement the solution
// NOTE: check out the caccl-api docs at bit.ly/caccl-api
Example Result
// Import caccl-api import initAPI from 'caccl-api';// Initialize the API const api = initAPI({ canvasHost: 'canvas.harvard.edu', accessToken: '1895~sdjfoa9me098fjoiasnudo8f7am9sod8ufnoaisdunfkuasdf', defaultCourseId: 53450, });
// In parallel, get course content const [ pages, assignments, discussionTopics, ] = await Promise.all([ // List pages api.course.page.list(), // List assignments api.course.assignment.list(), // List discussion topics api.course.discussionTopic.list(), ]);
// Import caccl-api
import initAPI from 'caccl-api';
// Initialize the API
const api = initAPI({
// TODO: fill this in
});
// TODO: implement the solution
// NOTE: check out the caccl-api docs at bit.ly/caccl-api
Example Result
// Import caccl-api import initAPI from 'caccl-api';// Initialize the API const api = initAPI({ canvasHost: 'canvas.harvard.edu', accessToken: '1895~sdjfoa9me098fjoiasnudo8f7am9sod8ufnoaisdunfkuasdf', defaultCourseId: 53450, });
/** * Create a unique test assignment within the sandbox course * @author Gabe Abrams * @returns assignment object that was created */ const createTestAssignment = async () => { // Generate a unique assignment name const uniqueAssignmentName =
Test Assignment [${Date.now()}-${Math.random()}]
;// Create an assignment const assignment = await api.course.assignment.create({ name: uniqueAssignmentName, submissionTypes: ['none'], published: true, });
// Return the new assignment return assignment; };
/** * Create a unique test page within the sandbox course * @author Gabe Abrams * @returns page object that was created */ const createTestPage = async () => { // Generate a unique page title const uniquePageTitle =
Test Page [${Date.now()}-${Math.random()}]
;// Create a page const page = await api.course.pages.create({ title: uniquePageTitle, body: 'This is a test page.', published: true, });
// Return the new assignment return assignment; };
// Run create + list tasks in parallel const [ assignments, pages, ] = await Promise.all([ // Create an assignment and then get the list of assignments (async () => { // Create an assignment await createTestAssignment();
<b>// Get the list of assignments</b> <b>const assignments = await api.course.assignment.list();</b> <b>// Return the list of assignments</b> <b>return assignments;</b>
})(), // Create a page and then get the list of pages (async () => { // Create a page await createTestPage();
<b>// Get the list of pages</b> <b>const pages = await api.course.page.list();</b> <b>// Return the list of pages</b> <b>return pages;</b>
})(), ]);
With asynchronous code, error handling is nearly the same: simply wrap your code in a try/catch
block:
// Async task that doesn't return anything
try {
await doAsyncTask();
} catch (err) {
// Handle error
}
// Async task that returns something
let userEmail: string;
try {
userEmail = await getUserEmail();
} catch (err) {
// Handle error
}
The most important thing to remember is that asynchronous tasks must be awaited from inside the try/catch otherwise, code execution will continue and leave the try/catch
block before the error occurs.
When handling errors that occur in Promise.all
parallel execution, it's important to note that Promise.all
throws an error immediately as soon as any of the promises passed into it throw an error. If an error occurs, only the first error will be thrown by Promise.all
. Other tasks will continue to execute, but their results will be ignored.
If you don't want all of the tasks in the Promise.all
to be ignored if one of the tasks encounters an error, simply group them together and wrap the internal tasks with try/catch
blocks. The best way to explain this is by example:
Here's what it would look like if we wanted the code to quit if any of the tasks fail:
try {
const [
pages,
assignments,
serverList,
] = await Promise.all([
api.course.page.list(),
api.course.assignment.list(),
getListOfServers(),
]);
} catch (err) {
// Handle error
}
However, let's say that we want it to work such that if one of two first Canvas-based tasks fails, we don't want the getListOfServers
task to be ignored. We could separate the Canvas-based tasks into a separate asynchronous function and handle the error within that function:
/**
* Load pages and assignments. If a failure occurs, instead of failing, we
* will return an empty array
* @author Gabe Abrams
* @returns pages and assignments
*/
const loadCanvasData = async () => {
try {
const [
pages,
assignments,
] = await Promise.all([
api.course.page.list(),
api.course.assignment.list(),
]);
return {
pages,
assignments,
};
} catch (err) {
return {
pages: [],
assignments: [],
};
}
};
try {
const [
canvasData,
serverList,
] = await Promise.all([
loadCanvasData(),
getListOfServers(),
]);
const {
pages,
assignments,
} = canvasData;
} catch (err) {
// Handle error
}
If the tasks are simple and it's convenient to put the asynchronous task inline, you can skip the step of creating a named function by instead creating an anonymous function and immediately calling it:
try {
const [
canvasData,
serverList,
] = await Promise.all([
(async () => {
try {
const [
pages,
assignments,
] = await Promise.all([
api.course.page.list(),
api.course.assignment.list(),
]);
return {
pages,
assignments,
};
} catch (err) {
return {
pages: [],
assignments: [],
};
}
})(),
getListOfServers(),
]);
const {
pages,
assignments,
} = canvasData;
} catch (err) {
// Handle error
}
Refrain from using callbacks unless absolutely necessary. If it's a lib function that requires a callback, wrap it in a helper and then use the helper.
Refrain from using callbacks. If a lib requires one, wrap and forget it// Bad:
setTimeout(task, 1000);
// Good:
/**
* Wait for a specific amount of time
* @author Gabe Abrams
* @param msToWait the number of ms to wait before continuing
*/
const waitFor = async (msToWait: number) => {
return new Promise((resolve) => {
setTimeout(resolve, msToWait);
});
};
await waitFor(1000);
task();
All our front-end development are done within the React framework. We pair our React apps with the following technologies:
- Bootstrap v5 for Styling
- FontAwesome for Glyphs
In favor of built-in React functionality, we do not use:
- Redux
We no longer use any of the following technologies:
- jQuery
Features to use only after a discussion with the team because these features require everyone to be on the same page:
- React Context Providers (useContext hook)
We organize our React projects with the following file structure:
client/src/
index.tsx // Entry point
App.tsx // Main component
Menu.tsx // Non-shared component
shared/
styles/
style.scss // Main shared stylesheet
constants/
MY_SHARED_CONSTANT.tsx // A shared constant
helpers/
mySharedHelper.tsx // A shared helper
MySharedComponent.tsx // A shared component
...
Each project will either use class-based or functional components, but will not mix and match. New projects will always use functional components.
Projects do not mix and match functional vs class-based components New projects use functional components onlyAll file management rules apply to components as well (naming conventions, file vs folder modules, etc.), so be sure to check out the section earlier in this guide.
All components must follow a shared structure. Sections that are not used may be left out, but any included sections must be formatted appropriately, named properly, and in the same order as below:
Components must follow our template formatting, order, and naming/**
* Add component description
* @author Add Your Name
*/
// Import React
import React, { useReducer, useEffect, useRef } from 'react';
// Import FontAwesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
// Import shared helpers
import addHelperName from './addHelperFilename';
// Import shared constants
import ADD_CONSTANT_NAME from './addConstantFilename';
// Import shared types
import AddSharedTypeName from './AddSharedTypeFilename';
// Import shared components
import AddSharedComponentName from './AddSharedComponentFilename';
// Import helpers
import addHelperName from './addHelperFilename';
// Import constants
import ADD_CONSTANT_NAME from './addConstantFilename';
// Import types
import AddTypeName from './AddSharedTypeFilename';
// Import components
import AddComponentName from './AddComponentFilename';
// Import style
import './AddNameOfStylesheet.scss';
/*------------------------------------------------------------------------*/
/* -------------------------------- Types ------------------------------- */
/*------------------------------------------------------------------------*/
// Props definition
type Props = {
// Add description of required prop
addPropName: addPropType,
// Add description of optional prop
addPropName?: addPropType,
};
// Add description of custom type
type AddCustomTypeName = {
// Add description of property
addCustomPropName: addCustomPropType,
};
/*------------------------------------------------------------------------*/
/* ------------------------------ Constants ----------------------------- */
/*------------------------------------------------------------------------*/
// Add description of constant
const ADD_CONSTANT_NAME = 'add constant value';
/*------------------------------------------------------------------------*/
/* -------------------------------- State ------------------------------- */
/*------------------------------------------------------------------------*/
/* -------------- Views ------------- */
enum View {
// Add description of view
AddViewName = 'AddViewName',
}
/* -------- State Definition -------- */
type State = (
| {
// Current view
view: View.AddViewName,
// Add description of require state variable
addStateVariableName: addStateVariableValue,
// Add description of optional state variable
addStateVariableName?: addStateVariableValue,
}
| {
// Current view
view: View.AddViewName,
// Add description of require state variable
addStateVariableName: addStateVariableValue,
// Add description of optional state variable
addStateVariableName?: addStateVariableValue,
}
);
/* ------------- Actions ------------ */
// Types of actions
enum ActionType {
// Add description of action type
AddActionTypeName = 'AddActionTypeName',
}
// Action definitions
type Action = (
| {
// Action type
type: ActionType.AddActionTypeName,
// Add description of required payload property
addPayloadPropertyName: addPayloadPropertyType,
// Add description of optional payload property
addPayloadPropertyName?: addPayloadPropertyType,
}
| {
// Action type
type: (
| ActionType.AddActionTypeWithNoPayload
| ActionType.AddActionTypeWithNoPayload
),
}
);
/**
* Reducer that executes actions
* @author Add Your Name
* @param state current state
* @param action action to execute
* @returns updated state
*/
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.AddActionType: {
return {
...state,
addStateVariableName: addStateVariableNewValue,
};
}
default: {
return state;
}
}
};
/*------------------------------------------------------------------------*/
/* --------------------------- Static Helpers --------------------------- */
/*------------------------------------------------------------------------*/
/**
* Add description of helper
* @author Add Your Name
* @param addArgName add arg description
* @param addArgName add arg description
* @returns add return description
*/
const addHelperName = (
addRequiredArgName: addRequiredArgType,
addOptionalArgName?: addOptionalArgType,
addOptionalArgWithDefaultName?: addOptionalArgType = addArgDefault,
): addReturnType => {
// TODO: implement
};
/*------------------------------------------------------------------------*/
/* ------------------------------ Component ----------------------------- */
/*------------------------------------------------------------------------*/
const AddComponentName: React.FC<Props> = (props) => {
/*------------------------------------------------------------------------*/
/* -------------------------------- Setup ------------------------------- */
/*------------------------------------------------------------------------*/
/* -------------- Props ------------- */
// Destructure all props
const {
addRequiredPropName,
addOptionalPropName = 'add default value of prop',
} = props;
/* -------------- State ------------- */
// Initial state
const initialState: State = {
addStateVariableName: 'add state variable initial value',
};
// Initialize state
const [state, dispatch] = useReducer(reducer, initialState);
// Destructure common state
const {
addStateVariableName,
addStateVariableName,
} = state;
/* -------------- Refs -------------- */
// Initialize refs
const addRefName = useRef<AddRefType>(null);
/*------------------------------------------------------------------------*/
/* ------------------------- Component Functions ------------------------ */
/*------------------------------------------------------------------------*/
/**
* Add component helper function description
* @author Add Your Name
* @param addArgName add description of argument
* @param [addOptionalArgName] add description of optional argument
* @returns add description of return
*/
const addComponentHelperFunctionName = (
addArgName: addArgType,
addOptionalArgName?: addOptionalArgType,
): addReturnType => {
// TODO: implement
};
/*------------------------------------------------------------------------*/
/* ------------------------- Lifecycle Functions ------------------------ */
/*------------------------------------------------------------------------*/
/**
* Mount
* @author Add Your Name
*/
useEffect(
() => {
(async () => {
// TODO: implement
})();
},
[],
);
/**
* Update (also called on mount)
* @author Add Your Name
*/
useEffect(
() => {
// TODO: implement
},
[addTriggerVariable],
);
/**
* Unmount
* @author Add Your Name
*/
useEffect(
() => {
return () => {
// TODO: implement
};
},
[],
);
/*------------------------------------------------------------------------*/
/* ------------------------------- Render ------------------------------- */
/*------------------------------------------------------------------------*/
/*----------------------------------------*/
/* ---------------- Modal --------------- */
/*----------------------------------------*/
// Modal that may be defined
let modal: React.ReactNode;
/* ------- AddFirstTypeOfModal ------ */
if (addLogicToDetermineIfModalIsVisible) {
// TODO: implement
// Create modal
modal = (
<Modal
key="unique-modal-key"
...
/>
);
}
/*----------------------------------------*/
/* ---------------- Views --------------- */
/*----------------------------------------*/
// Body that will be filled with the current view
let body: React.ReactNode;
/* -------- AddFirstViewName -------- */
if (view === View.AddViewName) {
// TODO: implement
// Create body
body = (
<addJSXOfBody />
);
}
/* -------- AddSecondViewName -------- */
if (view === View.AddViewName) {
// TODO: implement
// Create body
body = (
<addJSXOfBody />
);
}
/*----------------------------------------*/
/* --------------- Main UI -------------- */
/*----------------------------------------*/
return (
<addContainersForBody>
{/* Add Modal */}
{modal}
{/* Add Body */}
{body}
</addContainersForBody>
);
};
/*------------------------------------------------------------------------*/
/* ------------------------------- Wrap Up ------------------------------ */
/*------------------------------------------------------------------------*/
// Export component
export default AddComponentName;
The template above is highly involved, so let's break it down into pieces. Remember that you can leave any sections out if they are irrelevant. For example, if your component does not have any state, leave out the entire section.
Let's go from top to bottom.
We use JSDoc to document each file and every single function. Add one or more author tags to every single JSDoc entry.
Here's an example for a forgot password button:
Add JSDoc to the top of every component file, describing the component/**
* Button that shows the "forgot password" panel
* @author Divardo Calicci
*/
Commit frequently. We will squash later, but it's nice to have a detailed history. Plus, this is great for your careers.
When naming css classes, always prefix every class with the name of the component/* For component Favorite Fruits: */
/* Good: */
.FavoriteFruits-container
/* Bad: */
.container
We prefer rem for elements that have the same sizing, independent of their context (independent of their parent styling and size).
We prefer em for elements that have variable sizing where their sizing depends on their parent or context.
Why? When people customize their browser settings (fonts, font size, viewport settings, etc.) it will mess up your layout and create unexpected layouts. Plus, this makes your tool more accessible (it adapts better to user settings).
Use rem or em units instead of px unitsWe aggressively divide imports by type because components often end up with many imports.
First, we import libraries such as react
. Then, we import any local files
// Import Lib
import Lib from 'lib';
// Import AnotherLib
import AnotherLib from 'another-lib';
// Import LocalModule
import LocalModule from 'local-module';
Additionally, we divide all local modules into categories. Up first are shared imports (items that are also imported by other modules), then we have non-shared imports (items that are not imported by any other module). We divide each of these into sub-categories: helpers, constants, types, and components.
Group local imports// Import shared helpers
import addHelperName from './addHelperFilename';
// Import shared constants
import ADD_CONSTANT_NAME from './addConstantFilename';
// Import shared types
import AddSharedTypeName from './AddSharedTypeFilename';
// Import shared components
import AddSharedComponentName from './AddSharedComponentFilename';
// Import helpers
import addHelperName from './addHelperFilename';
// Import constants
import ADD_CONSTANT_NAME from './addConstantFilename';
// Import types
import AddTypeName from './AddSharedTypeFilename';
// Import components
import AddComponentName from './AddComponentFilename';
Finally, always import resources (images, etc.) and stylesheets last. These are the only imports that can include file extensions.
Import the stylesheet last// Import style
import './AddNameOfStylesheet.scss';
The first type in your types section must always be your Props
(if your component has any).
Remember that optional props must be marked with the ?
symbol. We put default props in the "Set Up" section inside of the component itself, so leave out the defaults here.
Example:
// Props definition
type Props = {
// User's first name
userFirstName: string,
// User's last name
userLastName: string,
// True if the user is a teacher
isTeacher?: boolean,
// Handler for when the user wants to log in
onLogInClicked?: () => void,
};
All constants are named with all caps, words separated by underscores. We do this because the const
keyword is ubiquitous throughout our code, so const
does not always indicate that something is a module constant.
// Duration of fade out animation
const FADE_OUT_DURATION = 1500; // ms
Remember that all numbers must include their units.
Create a simple button component that takes a label, onClick function, variant, and ariaLabel as propsYou may be familiar with the React useState
hook. We take a more rigorous approach to component state, always requiring that state be managed by reducers. This forces us to keep our views and controllers separate. Thus, we achieve a fully separate MVC design.
For components that have different views, define a View
enum and base state definitions on the view. In our example, the component is a checkout panel.
enum View {
// View the cart
Cart = 'Cart',
// Add shipping and billing information
ShippingAndBillingForm = 'ShippingAndBillingForm',
// Review order details and confirm
Review = 'Review',
}
Then, base the sate off of views. Each view should have a separate state definition.
type State = (
| {
// Current view
view: View.Cart,
// List of items that are in the cart
cart: Items[],
}
| {
// Current view
view: View.ShippingAndBillingForm,
// List of items that are being purchased
cart: Items[],
// Shipping information
shippingInformation: {
// Shipping address
address: Address,
// Delivery instructions
deliveryInstructions: string,
// Phone number
phone: PhoneNumber,
},
// Billing information
billingInformation: (
| {
// True if same as shipping information
sameAsShippingInformation: true,
}
| {
// True if same as shipping information
sameAsShippingInformation: false,
// Billing address
address: Address,
// Phone number
phone: PhoneNumber,
}
),
}
| {
// Current view
view: View.Review
// Identifier of the pending transaction
transactionId: string,
// True if the user has accepted the terms
termsAccepted: boolean,
}
);
If the component only has one view, leave out the View
enum and create a state that has only one form:
type State = {
// List of email recipients
recipients: EmailAddress[],
// List of CC recipients
ccRecipients?: EmailAddress[],
// Text typed into the email subject line
subject: string,
// Text typed into the body of the email
body: string,
// True if the "sending" indicator is visible
sending: boolean,
};
We use reducers to manage state. That means that all state updates are handled through actions. This helps us to abstract away state updates, and helps state updates to be more reusable.
First, we create an ActionType
enum that defines all of the types of actions that can be dispatched.
// Types of actions
enum ActionType {
// Add a recipient to the email
AddRecipient = 'AddRecipient',
// Update the subject of the email
UpdateSubject = 'UpdateSubject',
// Reset the entire email form
ResetForm = 'ResetForm',
// Show the "email is being sent" indicator
ShowSendingIndicator = 'ShowSendingIndicator',
}
Then, define the type of each action object. If the action has any payload properties, give it a separate Action type definition. Then, group all actions that have no payload at the bottom into one shared type definition.
Define every action type with payload separately, group payload-less actions// Action definitions
type Action = (
| {
// Action type
type: ActionType.AddRecipient,
// Email address of the recipient
recipient: EmailAddress,
}
| {
// Action type
type: ActionType.UpdateSubject,
// New text in the subject line
subject: string,
}
| {
// Action type
type: (
| ActionType.ResetForm
| ActionType.ShowSendingIndicator
),
}
);
Finally, create a reducer that executes the state updates. This function can get quite involved, so remember to keep it very organized. If using the spread operator (...
) to merge state, always put ...state
as the first element in the list so properties are overwritten.
/**
* Reducer that executes actions
* @author Divardo Calicci
* @param state current state
* @param action action to execute
* @returns updated state
*/
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.AddRecipient: {
return {
...state,
recipients: [...state.recipients, action.recipient],
};
}
case ActionType.UpdateSubject: {
return {
...state,
subject: action.subject,
};
}
case ActionType.ResetForm: {
return {
recipients: [],
ccRecipients: [],
subject: '',
body: '',
sending: false,
};
}
case ActionType.ShowSendingIndicator: {
return {
...state,
sending: true,
};
}
default: {
return state;
}
}
};
Switch statements in Typescript (and Javascript) share a block scope, so it can get very confusing to reason about logic. Thus, we wrap each case in a closure.
Switch cases must be wrapped in curly brace closures// Bad:
switch (expression) {
case value1:
...
case value2:
...
default:
...
}
// Good:
switch (expression) {
case value1: {
...
}
case value2: {
...
}
default: {
...
}
}
If some of your actions can only be dispatched from within specific views, break your reducer into multiple sections surrounded with if
statements:
/**
* Reducer that executes actions
* @author Divardo Calicci
* @param state current state
* @param action action to execute
* @returns updated state
*/
const reducer = (state: State, action: Action): State => {
/* --------- Assignment List -------- */
if (state.view === View.AssignmentList) {
switch (action.type) {
case ActionType.FilterAssignments: {
...
}
default: {
return state;
}
}
}
/* ------------ Page List ----------- */
if (state.view === View.PageList) {
switch (action.type) {
case ActionType.FilterPages: {
...
}
default: {
return state;
}
}
}
/* ------------- Default ------------ */
return state;
};
If logic can be removed from the component because it does not depend on state or props, either create a small static helper or move the logic to another file and import it under "Import Helpers".
Static helpers and imported helpers should not heavily rely on state or propsOtherwise, static helpers follow usual rules: add JSDoc, types, etc.
/**
* Get time of day
* @author Divardo Calicci
* @param timezone timezone of the current user
* @returns text describing the time of day
*/
const getTimeOfDay = (timezone: Timezone): string => {
...
};
The first thing that happens inside the component function is labeled "Setup" and it contains initialization for the props and state.
First, destructure props and include default props directly inline.
// Destructure all props
const {
name,
email,
age,
isTeacher = false,
profileColor = Color.Blue,
} = props;
Then, define the initial state. In this way, default props and state are right next to each other and easy to find.
// Initial state
const initialState: State = {
isOnline: false,
};
Then, initialize the state and destructure all state variables that are shared amongst all forms of the state.
// Initialize state
const [state, dispatch] = useReducer(reducer, initialState);
// Destructure common state
const {
isOnline,
} = state;
Finally, initialize refs (if you have any).
// Initialize refs
const inputElem = useRef<HTMLInputElement>(null);
...
<input ref={inputElem} ...
To get the element attached to a ref, use inputElem.current
.
React programmers often speak about the "lifecycle" of a component. When a component first is included in the UI, we say that the component is "Mounted". Then, when the props or state change, we say that the component "Updated". Note that when the component first receives its props and state (during the mount step), we also consider this as an update. Finally, when the component leaves the UI, we say that the component is "Unmounted". We use three different types of lifecycle functions if we want to trigger code when one of these lifecycle states occurs.
To trigger code when the component mounts, we add a useEffect(handler, [])
hook.
/**
* Mount
* @author Divardo Calicci
*/
useEffect(
() => {
// TODO: implement
},
[],
);
The "Mount" lifecycle function is great for running any asynchronous loading code (example: API request). If your code is asynchronous, wrap it in an anonymous async function. This trick works for any other lifecycle function, but we will only show it used here.
/**
* Mount
* @author Divardo Calicci
*/
useEffect(
() => {
(async () => {
// Check if the user is online
const { isOnline } = await getUserState();
// Update the sate
dispatch({
type: ActionType.CHANGE_USER_STATUS,
isOnline,
});
})();
},
[],
);
To trigger code when a prop or state variable changes, we use a useEffect(handler, [trigger])
hook. The trigger array can be one or more props and/or state variables that will cause the handler to be called. We recommend keeping your trigger array as concise as possible.
/**
* Update (also called on mount)
* @author Divardo Calicci
*/
useEffect(
() => {
// Play a "message received" sound
audioPlayer.play(Sound.MessageReceived);
},
[messages],
);
If you need multiple different "Update" lifecycle functions with different triggers, add multiple "Update" functions, each with a different trigger array.
You may hear about another usage for the useEffect
hook where the trigger array is left out completely: useEffect(handler)
, which corresponds to a trigger that happens whenever anything changes. Refrain from using this hook unless it's absolutely necessary. Usually, if you want to use this hook, that means that either you are doing logic that will need to happen every render (so it might as well be part of the render function), or your logic doesn't need to be run that frequently (so you should be more careful with when your logic runs, which you do by adding a trigger array).
To trigger code when a component leaves the UI, we return a handler function within a useEffect(handler, [])
hook. This is a great lifecycle function for cleanup.
/**
* Unmount
* @author Divardo Calicci
*/
useEffect(
() => {
return () => {
// Save the user's changes
saveChanges();
};
},
[],
);
In each of the lifecycle functions we learned above, you'll see effective usage of the useEffect
hook, which is one of the more complex hooks. It's not necessary that you fully understand this hook in order to do software development at DCE, but it's a good hook to understand. Thus, we'll go over it in some detail just for fun. This is a slightly simplified and modified description, see the docs for full detail.
The useEffect
hook is used to watch for changes to prop variables (so we can make updates when prop variables change) and also to watch for destruction of such variables (we we can do cleanup). Here's how it works:
useEffect
takes two arguments. The first argument is the effect function that is called when the changes are detected. The second argument is the list of prop variables to watch.
When any of the prop variables in the list change, the effect function is called. Also, on mount, those prop variables go from not existing to having values (that counts as a change), so the effect function is called on mount. The effect function contains the code that React should run when the changes are detected, and that is quite straightforward. But, the effect function also does something a little tricky: it's allowed to return another function (a cleanup function) that will be called when the component unmounts (this is a simplification).
Let's go through a few examples to make sense of this:
useEffect(
() => {
console.log('Hello!');
},
[],
);
In the example above, the array of prop variables to watch is empty. Thus, the effect function will be called on mount (because it's always called on mount) but will not be triggered at any point afterward. The effect function contains just a console.log and does not return a cleanup function, so there is no code here that will be called upon unmount. That's why we consider this useEffect
usage to be a "Mount" function.
useEffect(
() => {
console.log('Hello!');
},
[users],
);
In the example above, the array of prop variables contains the "users" prop. Thus, the effect function will be called on mount and will also be called whenever the "users" prop changes value. The effect function contains just a console.log and does not return a cleanup function, so there is no code here that will be called upon unmount. That's why we consider this useEffect
usage to be an "Update" function.
useEffect(
() => {
return () => {
console.log('Hello!');
};
},
[],
);
In the example above, the array of prop variables to watch is empty. Thus, the effect function will be called on mount (because it's always called on mount) but will not be triggered at any point afterward. The effect function only contains a return statement and does not run any other code, so in effect, there is no code to run on mount but there is code to run on unmount (the returned function will be called on unmount). That's why we consider this useEffect
usage to be an "Unmount" function.
At DCE, these are the only three usages that we'll allow for useEffect
, but at other organizations they may allow much more complex useEffect
implementations. We keep things simple and clear for understandability and readability.
You may have noticed that we separate sections of our code with comment blocks. Aside from the usual very large and wide comment block delimiting sections of the code, we use two smaller types of comment blocks to organize our render functions.
Medium blocks to separate parts of the UI:
/*----------------------------------------*/
/* ---------------- Modal --------------- */
/*----------------------------------------*/
And line comments to organize different types of that part of the UI:
/* ----------- Error Modal ---------- */
/* ------- Confirmation Modal ------- */
When rendering views, it is okay to use if
statements along with the view
state variable (instead of if
else if
logic) because this keeps our code separate and readable.
At the end of the component's render code, there should be just one return statement that returns the main UI.
Components can have only one return statement/*----------------------------------------*/
/* --------------- Main UI -------------- */
/*----------------------------------------*/
return (
<div className="EmailForm-outer-container">
{/* Add Modal */}
{modal}
{/* Add Body */}
{body}
</div>
);
This final section is usually very short. Put any final export logic here.
// Export component
export default EmailForm;
// Bad:
export default 50;
// Bad:
export default {
...
};
// Good:
export default myVariableName;
// Good:
export default MyClass;
Writing JSX code takes some getting used to. In many cases, it looks and feels just like HTML, but on certain cases, it differs in very small ways. Let's go through JSX by reviewing some of my favorite tips.
JSX is a combination of HTML and Typescript. The most important thing you'll learn about JSX is how to inject typescript into html. Typescript is always surrounded by {...}
within our JSX blocks. Let's learn through examples:
Add a dynamically generated template string for the button aria-label:
<button
type="button"
className="btn btn-warning"
aria-label={`log out ${currentUserName}`}
>
Log Out
</button>
Add a variable as the value for an onClick function:
<button
type="button"
className="btn btn-warning"
onClick={logOut}
>
Log Out
</button>
Add an inline function that's called when the user clicks a button:
<button
type="button"
className="btn btn-warning"
onClick={() => {
console.log('Logging out now!');
}}
>
Log Out
</button>
This works for props and also normal html contents:
<button
type="button"
className="btn btn-warning"
>
Log Out
{' '}
{numActiveUsers}
{' '}
Users
</button>
Any typescript can be placed inside the {...}
, for example, math:
<button
type="button"
className="btn btn-warning"
>
Log Out
{' '}
{numActiveUsers + 1}
{' '}
Users
</button>
We can also use conditional logic, just like usual in typescript:
<button
type="button"
className="btn btn-warning"
>
{
numActiveUsers === 1
? 'Log Out Current User'
: `Log Out ${numActiveUsers} Users`
}
</button>
Instead of using class="..."
, we use className="..."
in JSX.
In HTML, event handlers are named in all lowercase like onmousedown
but in JSX, we use camel case like onMouseDown
.
To add a normal breaking space, use {' '}
on its own line.
To add a non-breaking space, use
.
To keep our JSX clean and to help with rendering, we always encode symbols.
Always encode symbols in JSXHere are a few for reference:
&
becomes&
<
becomes<
>
becomes>
"
becomes"
'
becomes'
¢
becomes¢
©
becomes©
®
becomes®
All modals should be dce-reactkit
modals with unique keys.
<Modal
key="unique-modal-key"
...
/>
To add a FontAwesome icon, make sure the icon is imported. For example, if we want a checkmark, we will import the faCheck
icon:
// Import FontAwesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
Usually, icon names are just the camelCase versions of the icon name, but autocomplete should help you here.
Then, when you want to use the icon, include it like this:
<FontAwesomeIcon icon={faCheck} />
You can also add a className
prop to FontAwesomeIcon
for bootstrap styles:
<FontAwesomeIcon
icon={faCheck}
className="m-2"
/>
In HTML, you may see a tag that has no contents:
<div id="LoginButton-container"></div>
...but in JSX, we use self-closing tags:
<div id="LoginButton-container" />
We spent a lot of time defining the state and reducer, but how do you dispatch an action to the reducer?
Simple: call the dispatch
function and pass an object with a type
property and any other payload properties that are defined for that action type.
dispatch({
type: ActionType.UpdateSubject,
subject: inputField.value,
});
Unless something in JSX has just one item, put it on multiple lines. This keeps our UI code very clean and readable.
Prefer multiline JSX{/* Good */}
<div id="LoginButton-container" />
{/* Equally Good */}
<div
id="LoginButton-container"
/>
{/* Bad */}
<div id="LoginButton-container" className="row" />
{/* Good */}
<div
id="LoginButton-container"
className="row"
/>
In JSX file, we mix and match typescript and html code.
Unless you're embedding a single variable:
<button
onClick={handleClick}
...
...or you're embedding a single value:
<button
type="button"
...
...don't mix and match typescript and html code on the same line.
Don't mix typescript and JSX{/* Bad: */}
<button
onClick={() => { handleClick(); }}
...
/>
{/* Good: */}
<button
onClick={() => {
handleClick();
}}
...
/>
{/* Bad: */}
<div className="Role-description">
You are a {user.role} in the course.
</div>
{/* Good: */}
<div className="Role-description">
You are a
{' '}
{user.role}
{' '}
in the course
</div>
If state must be shared between multiple components, put those state variables in the lowest common ancestor and pass state down to children via props.
This can cause what is called "prop drilling", where state is passed through multiple layers of children via their props. If this becomes particularly cumbersome, it might be one of the cases where we use a React Context Provider to create a shared state, but this needs to be a full team decision.
Never create a React Context Provider without discussing with the rest of the teamUnless absolutely necessary, never use images. Great alternatives are glyphs via FontAwesome
or svg vector graphics. If you think that an image is required, discuss the situation with the team. We do this to aggressively keep our apps small in size. We have students all over the world, many with very slow internet connections, so load time is very important.
You'll often hear the term "fat" vs "thin" reducers, which refers to whether logic is concentrated inside the reducer or outside the reducer, respectively. We love "fat" reducers. As much as possible, move logic into the reducer. In my opinion, "thin" reducers defeat the entire purpose of reducers. When writing "thin" reducers, you often create an entire action that just passes through state updates. In this case, you lose all of the benefits of abstraction that useReducer
offers and you might as well just replace it with useState
.
If you have shared constants, put them in a constants/
folder either in client/src/shared/
if they're shared across the whole app or put them directly in the folder for the current component if they're shared across a component and its children.
Constant modules are simple:
// MAX_STUDENTS_PER_TABLE.tsx
/**
* Maximum number of students allowed at a table
* @author Divardo Calicci
*/
const MAX_STUDENTS_PER_TABLE = 500; // people
export default MAX_STUDENTS_PER_TABLE;
If you have shared enums or types, put them in a types/
folder either in client/src/shared/
if they're shared across the whole app or put them directly in the folder for the current component if they're shared across a component and its children.
Type modules are simple:
// Car.tsx
/**
* Car type
* @author Divardo Calicci
*/
type Car {
// Company that made the car
make: string,
// Year that the car was manufactured
year: number,
// Color of the car
color: AllowedColors,
}
export default Car;
Enum modules are simple:
// AllowedColors.tsx
/**
* Allowed paint colors for new cars
* @author Divardo Calicci
*/
enum AllowedColors {
Red = 'Red',
Green = 'Green',
Blue = 'Blue',
}
export default AllowedColors;
It seems like a lot of word to re-type the name of enum values in lowercase, but we do that for a good reason: it helps with readable serialization and database entries that are more debuggable and readable. Because this is so valuable, we require it.
Enums must have string value unless the value's natural type is a number// Bad:
enum AllowedColors {
Red,
Green,
Blue,
}
// Good:
enum AllowedColors {
Red = 'Red',
Green = 'Green',
Blue = 'Blue',
}
// Bad (allowed colors are not numbers)
enum AllowedColors {
Red = 1,
Green = 2,
Blue = 3,
}
// Good (number of wheels is a number naturally)
enum VehicleWheelConfig {
Bike = 2,
Tricycle = 3,
Car = 4,
FlatbedTruck = 16,
}
If you have shared helpers, put them in a helpers/
folder either in client/src/shared/
if they're shared across the whole app or put them directly in the folder for the current component if they're shared across a component and its children.
Simply define the function and then export it.
// roundToTwoDecimals.tsx
/**
* Round a number to two decimals
* @author Divardo Calicci
* @param num the number to round
* @returns the rounded number
*/
const roundToTwoDecimals = (num: number) => {
return (
Math.round(
num * 100
)
/ 100
);
};
export default roundToTwoDecimals;
If Bootstrap has a class for the style or layout that you want to use, take advantage of Bootstrap. Check out the Bootstrap docs. They're great.
But if you must create a new style, put it in an associated .scss
file. If that style is shared, see the next section on "Shared Styles"
If you have shared styles, put them in a styles/
folder either in client/src/shared/
if they're shared across the whole app or put them directly in the folder for the current component if they're shared across a component and its children.
Great candidates for these styles are:
- Shared classes like
btn-nostyle
- Shared custom color variables
- Shared custom number variables
Generally, we try to limit the number of shared style because we use Bootstrap so much. But, as a team, we may put some shared styles into a client/src/shared/styles/style.scss
file.
To import a stylesheet that includes variables that you'd like to use, do it with the @use
command:
@use '../shared/styles/style.scss';
When defining variables, do so at the top of your scss file:
$border-width: 0.5rem;
To improve our tools, inform teaching practices, and provide analytics to faculty and staff, we log user errors and actions. Because logging is done so commonly, we use a common logging service from dce-reactkit
.
In your app, make sure you've followed instructions on setting up connections to the database. Then, add another "Log" collection to your /server/src/shared/helpers/mongo.ts
file:
Import dce-reactkit
dependencies at the top of the file:
// Import dce-reactkit
import { initLogCollection, Log } from 'dce-reactkit';
Initialize the log collection near the bottom of the file:
// Logs
export const logCollection = initLogCollection(Collection) as Collection<Log>;
And as always, whenever you make a change to this file, increment the schemaVersion
variable.
Then, in the server index of your app, initialize dce-reactkit
by importing the appropriate dependencies near the top:
// Import CACCL
import initCACCL, { getLaunchInfo } from 'caccl/server';
// Import dce-reactkit
import { initServer } from 'dce-reactkit';
// Import log collection
import { logCollection } from './shared/helpers/mongo';
Then, within the initCACCL
, at the top of the express.postprocessor
function, add code to initialize dce-reactkit
with logging:
const main = async () => {
// Initialize CACCL
await initCACCL({
...
express: {
postprocessor: (app) => {
// Initialize dce-reactkit
initServer({
app,
getLaunchInfo,
logCollection,
});
...
},
},
});
};
...
Now that you've set up support for logging, take some time to think through how you want to organize the logs. We'll always divide logs into error
and action
logs, where action
logs contain user actions such as opening panels, clicking buttons, interacting with elements, etc. It's up to you how you want to organize your action logs.
We'll be curating a /server/src/shared/types/LogMetadata.ts
file (that should be synched to the client as well) that will hold all of the reusable log organization metadata (which you will learn about in later sections). Add an empty file to get started:
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// TODO: add metadata
};
export default LogMetadata;
There are two ways you will organize your logs:
Each action log entry must be tied to a "context" and can optionally be tied to a "subcontext" as well. You may choose for contexts to represent features in your app, where subcontexts represent subfeatures. Or perhaps contexts represent pages/panels in your app. Or perhaps contexts represent different parts of a toolbox.
Once you've determined how you want to structure your contexts and subcontexts, add a section to the LogMetadata.ts
file. Add each context to the LogMetadata.Context
object as a string key where the value of each key matches the key itself:
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
Context: {
AttendancePanel: 'AttendancePanel',
AnalyticsDashboard: 'AnalyticsDashboard',
Roster: 'Roster',
},
};
export default LogMetadata;
If your app will make use of subcontexts, simply nest the context object and move the string value to an inner key called _
that is placed at the top of the list of children. In the following example, we have subcategories within "AnalyticsDashboard" that represent different views inside of the analytics dashboard: "StudentView", "ClassView", and "ProgramView".
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
Context: {
AttendancePanel: 'AttendancePanel',
AnalyticsDashboard: {
_: 'AnalyticsDashboard',
StudentView: 'StudentView',
ClassView: 'ClassView',
ProgramView: 'ProgramView',
},
Roster: 'Roster',
},
};
export default LogMetadata;
However you choose to organize your logs, make sure you future-proof your structure so that it can grow as your app changes. It's not fun to have to go back through old log entries and perform migrations. Instead, it's best to create future-proofed contexts and subcontexts that can be augmented and expanded. To better understand future-proofing, let's discuss an example: you are working on an app called "Math Toolbox" and you anticipate that it'll eventually have lots of sub-tools with different purposes. Currently, you've only created the "Calculator" subtool.
Here's an example of poor organization where contexts are not future-proofed:
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
Context: {
Algebra: 'Algebra',
Calculus: 'Calculus',
Graphing: 'Graphing',
},
};
export default LogMetadata;
Each context (Algebra, Calculus, Graphing) represents one part of the current calculator app. This will become really confusing and cluttered as more tools get added to our Math Toolbox tool. Perhaps the next tool will be a data science tool or a visualization tool. When more tools get added to our Math Toolbox, our context space will get more cluttered and it'll be hard to tell which context belongs to each tool. Plus, if other tools in our Math Toolbox have similar functions (perhaps the data science tool also has a graphing function), then we would have to add another context for that other tool and we'd have to give it a confusing name like GraphingForDataScience
.
Here's a slightly improved way to organize contexts, but even this is not good enough:
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
Context: {
CalculatorAlgebra: 'CalculatorAlgebra',
CalculatorCalculus: 'CalculatorCalculus',
CalculatorGraphing: 'CalculatorGraphing',
},
};
export default LogMetadata;
In the example above, at least it's clear which tool each context belongs to, but it'll still be too cluttered as we further develop the tool.
Here's an example of good future-proofing:
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
Context: {
Calculator: {
_: 'Calculator',
Algebra: 'Algebra',
Calculus: 'Calculus',
Graphing: 'Graphing',
},
},
};
export default LogMetadata;
Another way you can organize logs is through tags. These tags are flexible, optional, and very simple. You decide which tags to use for your app. The main thing to think about is clutter-reduction. In particular, it's easy to create an endless list of tags that are confusing and sometimes indistinguishable from each other.
If you choose to use tags, once you've decided on a list of tags, add them to your LogMetadata.ts
file:
// Import dce-reactkit
import { LogMetadataType } from 'dce-reactkit';
/**
* Log contexts, tags, and other metadata
* @author Your Name
*/
const LogMetadata = {
// Contexts
...
// Tags
Tag: {
StudentFeature: 'StudentFeature',
TeacherFeature: 'TeacherFeature',
AdminFeature: 'AdminFeature',
PilotFeature: 'PilotFeature',
RequiresLogin: 'RequiresLogin',
RequiresRegistration: 'RequiresRegistration',
},
};
export default LogMetadata;
Each log entry is assigned a "log level" which determines the type of information the log contains. For example, "warn" level logs contain critical information, "info" level logs contain normal user story information, and "debug" level logs contain highly detailed information that might be necessary for fine-grained tracking or debugging.
By default, log entries are assigned the "info" log level. If you want to use another log level, simply pass it in while logging as level
:
logServerEvent({
...
level: LogLevel.Warn,
});
Where LogLevel
can be imported from dce-reactkit
:
// Import dce-reactkit
import { LogLevel } from 'dce-reactkit';
If context, subcontext, and tags aren't enough, you can add custom metadata individually to each log entry. The intent of the metadata field is to allow complicated actions or errors to provide additional information about the event. The metadata field should be an object with string keys and simple values (strings, numbers, booleans, etc.) but the metadata field is not intended for large amounts of data (images, etc.)
To add metadata to a log entry, simply pass it in while logging as metadata
:
logServerEvent({
...
metadata: {
my: 28,
custom: 'metadata',
can: 'have',
any: {
format: 'I want',
},
},
});
Where metadata
can be any JSON object, however, we do prefer shallow metadata for easier querying.
Whenever you log using dce-reactkit
, a whole bunch of useful data is added to each log entry:
Every log entry will automatically include the following user information taken directly from the user's Canvas info:
{
// First name of the user
userFirstName: string,
// Last name of the user
userLastName: string,
// User email
userEmail: string,
// User Canvas Id
userId: number,
// If true, the user is a learner
isLearner: boolean,
// If true, the user is an admin
isAdmin: boolean,
// If true, the user is a ttm
isTTM: boolean,
}
Every log entry will automatically include the following course information taken directly from the current Canvas course:
{
// The id of the Canvas course that the user launched from
courseId: number,
// The name of the Canvas course
courseName: string,
}
Every log entry will automatically include the following course information taken from the user's session information:
{
// Browser info
browser: {
// Name of the browser
name: string,
// Version of the browser
version: string,
},
// Device info
device: {
// Name of the operating system
os: string,
// If true, device is a mobile device
isMobile: boolean,
},
}
Every log entry will automatically include the following date and time information, recorded in ET where applicable:
{
// Calendar year that the event is from
year: number,
// Month that the event is from (1 = Jan, 12 = Dec)
month: number,
// Day of the month that the event is from
day: number,
// Hour of the day (24hr) when the event occurred
hour: number,
// Minute of the day when the event occurred
minute: number,
// Timestamp of event (ms since epoch)
timestamp: number,
}
If the log entry was created on the client, the entry will automatically include the following source flag:
{
// Source of the event
source: LogSource.Client,
}
Where LogSource
can be found in dce-reactkit
.
If the log entry was created on the server, the entry will automatically include the following source flag:
{
// Source of the event
source: LogSource.Server,
// Route path (e.g. /api/admin/courses/53450/blocks)
routePath: string,
// Route template (e.g. /api/admin/courses/:courseId/blocks)
routeTemplate: string,
}
Where route information is taken from express.
First, let's go over all the cases where logs are already automatically written. Whenever the renderErrorPage
function is called from within an endpoint, that will automatically be logged. Also, whenever an error is thrown on the server from within an endpoint, that error will also automatically be logged.
To write your own logs, all you need to do is call the appropriate logging function. On the client, simply import the logClientEvent
function from dce-reactkit
and call it:
// Import dce-reactkit
import { logClientEvent } from 'dce-reactkit';
...
logClientEvent({
...
});
If you're on the server, from within an endpoint that is using the genEndpointHandler
function, destructure the logServerEvent
function from the handler's arguments and call it:
app.post(
'/api/my/endpoint/path',
genRouteHandler({
...
handler: async ({ logServerEvent }) => {
...
logServerEvent({
...
});
Both the logServerEvent
and logClientEvent
functions take the same arguments. Thus, the process of logging on the server is identical to the process of logging on the client. See the section below depending on which type of log you’re making (error or action):
To log errors, call logServerEvent
or logClientEvent
with an "error" argument which is an Error instance:
logClientEvent({
context: LogMetadata.Context.AnalyticsDashboard,
error: myError,
});
You can also add any or all of the following: subcontext, tags, level (log level), and/or metadata:
logClientEvent({
context: LogMetadata.Context.AnalyticsDashboard,
error: myError,
subcontext: LogMetadata.Context.AnalyticsDashboard.StudentView,
tags: [LogMetadata.Tag.RequiresLogin],
level: LogLevel.Debug,
metadata: {
userHasPopupBlocker: true,
},
});
Where LogLevel
can be imported from dce-reactkit
: import { LogLevel } from 'dce-reactkit';
.
We automatically record error.message
, error.code
, and error.stack
. If you want to manually determine those, try creating a new ErrorWithCode
:
// Import dce-reactkit
import { ErrorWithCode } from 'dce-reactkit';
...
const error = new ErrorWithCode(
'Add a message here',
ErrorCode.MyErrorCode,
);
When querying logs, note that error information is spread out across three variables for simplicity:
{
// The error message
errorMessage: string,
// The error code
errorCode: string,
// Error stack trace
errorStack: string,
}
An action is any event that is either triggered by a user or is encountered by a user. To log actions, call logServerEvent
or logClientEvent
with an action
and an optional target
argument. If the action is being performed on the context itself (for example, the user is opening the AnalyticsDashboard), then the target should be left blank. Otherwise, the target should be included and should be a target taken directly from LogMetadata.Target
. For a full list of available types of actions, see the list of dce-reactkit log actions.
Example of opening the context:
// Import dce-reactkit
import { LogAction } from 'dce-reactkit';
...
logServerEvent({
context: LogMetadata.Context.AnalyticsDashboard,
action: LogAction.Open,
});
Example of doing an action within a context:
// Import dce-reactkit
import { LogAction } from 'dce-reactkit';
...
logServerEvent({
context: LogMetadata.Context.AnalyticsDashboard,
action: LogAction.Remove,
target: LogMetadata.Target.WatchSpeedWidget,
});
As with errors, you can add more information to your action log entry by adding a subcontext, tags, level (log level), and/or metadata:
logClientEvent({
context: LogMetadata.Context.AnalyticsDashboard,
subcontext: LogMetadata.Context.AnalyticsDashboard.StudentView,
action: LogAction.Remove,
target: LogMetadata.Target.WatchSpeedWidget,
tags: [
LogMetadata.Tag.PilotFeature,
LogMetadata.Tag.AdminFeature,
],
level: LogLevel.Warn,
metadata: {
analyticsInHighContrastMode: false,
},
});
Where LogLevel
can be imported from dce-reactkit
: import { LogLevel } from 'dce-reactkit';
.
There are many ways to query the logs, but here are some tips:
Take a look at the included dce-reactkit log types and filter by one of those. For example, if querying for just actions, use this query:
{ type: 'action' }
Use userId
, userEmail
, userFirstName
, and/or userLastName
to filter to a specific user.
Use courseId
and/or courseName
to filter to a specific course.
Use isLearner
, isAdmin
, or isTTM
to filter to specific user roles.
You can filter by where the error occurred (on the server, on the client, etc.) by taking a look at the included dce-reactkit sources. For example, if querying for server-side errors, use this query:
{ source: 'server' }
You can use the year
, month
, day
, hour
, minute
, etc. fields to query. For example, if we know that the issue occurred in January or February of 2023:
{
year: 2023,
month: { $in: [1, 2] },
}
If searching through action logs, you can take a look at the included dce-reactkit action types. For example, if you just want to find Open/Close/Cancel actions:
{
action: {
$in: ['open', 'close', 'cancel'],
},
}
As usual, you can combine any of the example filters above while also adding your own custom filters to your query. For example, here's a query to find all client-side errors that students experienced in 2022:
{
year: 2022,
isLearner: true,
source: 'client',
type: 'error',
}j
All of our React apps are served with an Express backend. This is a very intentional decision because it allows us to fluidly move items between the front-end and the back-end. This is possible because Express runs typescript in the same way as our React clients, plus all our dependencies can be added to either the server or the client. Finally, it allows us to share types across our server and client, instead of having to re-define them and manage two sets of types. None of this would be possible if we use a non-express, non-typescript backend (django, java, etc.).
If your app requires a server, it must use Express.
Make sure caccl
, dotenv
, and dce-reactkit
are added to your project dependencies.
In the top-level /server/src/index.ts
file, make sure you have the following key components of initialization:
// Import CACCL
import initCACCL, { getLaunchInfo } from 'caccl/server';
// Import dce-reactkit
import { initServer } from 'dce-reactkit';
// Import environment
import 'dotenv/config';
// Import route adders
import addRoutes from './addRoutes';
/**
* Initialize app server
* @author Your Name
*/
const init = async () => {
// Initialize CACCL
await initCACCL({
express: {
postprocessor: (app) => {
// Initialize dce-reactkit
initServer({
getLaunchInfo,
});
// Call route adders
addRoutes(app);
},
},
});
};
// Init server and display errors
init();
All servers should generally follow this structure from within the /server/src/
folder:
/index.ts – the main server file, described above
/addRoutes/index.ts – a script that calls all route adders
/addRoutes/addSomeTypeOfRoutes.ts – adds one type of routes (e.g. student API routes)
/addRoutes/addAnotherTypeOfRoutes.ts – adds one type of routes (e.g. teacher API routes)
Shared files go into the /server/src/shared/
folder:
/constants/
/helpers/
/types/
/classes/
/interfaces/
In each server, there should be a /server/src/addRoutes
module that contains all routes. Divide routes into sensible categories, and put them in a nested tree structure such that the top-level index.ts
file only needs to call one route adder: /server/src/addRoutes/index.ts
.
For example, let's say you have an app where there are really three types of routes: student API routes, teaching team member (TTM) API routes, and admin API routes. Within TTM routes, that is further subdivided into two sub-categories: teacher routes and TA routes. Thus, we'd divide our routes into the following folder structure:
/addRoutes/index.ts – a route adder function that calls all route adder subfolders
/addRoutes/addStudentAPIRoutes.ts – adds all student API routes
/addRoutes/addTTMAPIRoutes/index.ts – calls all adder TTM adder functions
/addRoutes/addTTMAPIRoutes/addTeacherRoutes.ts – adds all teacher routes
/addRoutes/addTTMAPIRoutes/addTARoutes.ts – adds all teacher routes
/addRoutes/addAdminRoutes.ts – adds all admin routes
We use REST API standards with additional constraints. When defining a route, always use the following rules to create your paths:
All API endpoint paths must start with `/api`Additionally, if only certain types of users can access the route, add another prefix for the type of user. Currently, we support admin
and ttm
:
For the endpoint method, follow these rules:
- Get data = GET
- Create or add something = POST
- Multipurpose create or modify something = POST
- Single-purpose modify something = PUT
- Delete or remove something = DELETE
We use a folder-like pluralized path structure for endpoints. All placeholders must be prefixed by a description of the value.
All placeholders must have a description prefixFor example, if you want to have a course id and a user id in the path, you must add prefixes:
Good:
/api/admin/courses/:courseId/users/:userId
Bad:
/api/admin/course/:courseId/user/:userId – not pluralized
/api/admin/courses/:courseId/:userId – userId has no prefix
/api/admin/:courseId/:userId – neither course nor user ids have prefixes
Add routes directly to the express app (for consistency across projects):
app.get(
'/api/ttm/videos/:videoId/transcripts/:transcriptId',
[handler here],
);
Add a JSDoc block above each endpoint, making sure to describe as @param
statements any parameters that are not included in the URL. For example, if we have an endpoint with two parameters in the url (videoId
and transcriptId
) and two parameters included in the request (format
and language
), the JSDoc would look like this:
/**
* Get the transcript for a video
* @author Gabe Abrams
* @param {string} format the format of the transcript to return
* @param {string} [language=en] the language to use for the transcript
* @returns {string} full video transcript
*/
app.get(
'/api/ttm/videos/:videoId/transcripts/:transcriptId',
[handler here],
);
All API routes should use the dce-reactkit
function for generating a route handler: genRouteHandler
, which handles auth, session management, security and privacy, parameter parsing, automatic error handling, crash prevention, and so much more.
The second argument of the express app.get
, app.post
, app.put
, app.delete
, or app.all
function is a route handler. Use genRouteHandler
to create such a handler.
genRouteHandler
takes one argument, which is an object that must contain a handler
function, may optionally contain a paramTypes
map defining the types of parameters, and may optionally contain a flag that turns off the session check. Each property is defined below:
Define all params that the user can include in the request, including params in the URL and params in the request body. For example, if we have an endpoint with two parameters in the url (videoId
and transcriptId
) and two parameters included in the request (format
and language
), the paramTypes
object should look like this:
app.get(
'/api/ttm/videos/:videoId/transcripts/:transcriptId',
genRouteHandler({
paramTypes: {
videoId: ParamType.Int,
transcriptId: ParamType.Int,
format: ParamType.String,
language: ParamType.StringOptional,
},
...
}),
);
We use the dce-reactkit
special param type enum called ParamType
, which supports the following types:
Boolean – required boolean
BooleanOptional – optional boolean
Float – required float
FloatOptional – optional float
Int – required int
IntOptional – optional int
JSON – required JSON object that has been stringified
JSONOptional – optional JSON object that has been stringified
String – required string
StringOptional – optional string
Note that it doesn't make sense to make a URL parameter be an optional param because they must always be included by construction.
The handler function is key because it handles the request. The handler function is an async
function that is tasked with either returning the value that should be sent in the response to the client, or the handler function should throw an error which would also be sent to the client.
To send an error code to the client, use the dce-reactkit
custom error called ErrorWithCode
.
To send a response to the client, simply return the value that you want to send to the client. If you don't want to return anything, simply return undefined;
.
Handler functions can take any of the following requirements, depending on what's required for the functionality you need to implement:
params
: a map containing all params defined inparamTypes
plus a whole host of other automatically included user information. See the list below for more informationreq
: the express request objectnext
: a function to call to call the next express handler in the stacknext()
send
: send a raw text response to the clientsend(text: string, [status: number])
renderErrorPage
: render a pretty html page that displays a server-side error (should not be used for API endpoints), takes an object that contain any of the following strings:title
,description
,code
,pageTitle
, as well as an optionalstatus
numberrenderInfoPage
: render a pretty html page that displays server-side info (should not be used for API endpoints), takes an object that contains the following strings:title
,body
.
Additional params added to the params
object in addition to params defined by paramTypes
:
/**
* Additional auto-included params:
* @param {number} userId the user's CanvasId
* @param {string} userFirstName the user's first name from Canvas
* @param {string} userLastName the user's last name from Canvas
* @param {string} userEmail the user's primary email from Canvas
* @param {string} userAvatarURL a link to the user's profile image
* @param {boolean} isLearner true if the user is a learner (student) in the Canvas course
* @param {boolean} isTTM true if the user is a teacher, TA, or other teaching team member in the Canvas course
* @param {boolean} isAdmin true if the user is a Canvas admin
* @param {number} courseId the id of the Canvas course that the user launched from
* @param {string} courseName the name of the Canvas course
*/
Further, all variables from the user's express session will be added to the params object if those variables are of type string
or boolean
or number
.
Here's an example:
app.get(
'/api/ttm/videos/:videoId/transcripts/:transcriptId',
genRouteHandler({
...
handler: async ({ params, next }) => {
...
if (somethingBadHappened) {
throw new ErrorWithCode(
'Error message with full description here',
'AX34', // Error code
);
}
...
if (needToCallNextHandler) {
return next();
}
...
// Process params and get transcript
const {
// Get request info
videoId,
transcriptId,
format,
language,
// Get more info
userId,
userFirstName,
} = params;
// Get the transcript
const transcript = getTranscript({
videoId,
transcriptId,
format,
language,
});
// Add user info to the transcript
const transcriptWithUserInfo = augmentWithUserInfo(transcript, userId, userFirstName);
// Send the response to the client
return transcriptWithUserInfo;
},
...
}),
);
If skipSessionCheck
is true, dce-reactkit
will skip its usual session checks (user must have launched via LTI and must have a valid session).
We use databases intentionally. Thus, it's important to understand when to use a database, and when not to.
Comparing memory vs database storage:
Factor | In Memory | In Database |
---|---|---|
Speed | Quick to access | Slow to access |
Permanence | Deleted upon instance restart | Stored indefinitely |
Distribution | Data must only be relevant to one user during their session | Data can be shared across multiple sessions, across multiple users, or across multiple server instances |
Migration | Automatically deleted when app is upgraded | Must be migrated when app is updated |
Backups | Never backed up | Automatically backed up on regular intervals |
Cleanup | If managed well, garbage collector takes care of this | Database will continue to grow if not managed carefully |
From this chart, it may seem like everything should go in a database, which is not necessarily true. Especially with user-specific information that is specific to the user's current session, it is extremely advantageous to store that information directly to the user's session (req.session
via express) instead of storing items in the database. This minimizes the number of I/O lookups that have to occur on every server request.
Here are a few scenarios to consider:
- A piece of user-specific data needs to be quickly accessible by the user and is not needed after the user's session expires. We'll put this in the user's session because it'll be fast to access and will be automatically cleaned up after the user ends their session.
- A piece of data needs to be extremely quick to access because it must be used by server-side algorithms, but this data is also required outside the user's session. We'll store this in a database but cache it in memory, so we get the best of both worlds...but we'll be careful to think through the impact of caching and potentially out-of-date data.
- A piece of data needs to be stored indefinitely and we'll continue to add to it over time. We'll store this in the database because that'll ensure the data is persistent and backed up.
- A piece of data needs to be accessed by multiple users. We'll store this in the database.
- A piece of data is very large. We'll store this in the database because we simply cannot leave it in memory without risk of running out of memory once multiple users use this feature.
Once you've decided what data should be in the database and what data should not be in the database, you can continue to the following section to integrate with mongo/docdb.
Because our projects are designed to go back and forth seamlessly between MongoDB and Amazon DocDB, we use a library called dce-mango
which provides the seamless interface that ensures operations are successfully executed and queries are consistently executed independent of the database type.
We only use non-relational database because these types of databases work extremely fluently with the rest of our javascript-based stack. In particular, mongo and docdb allow us to store javascript objects directly to the database, with a couple constraints. Thus, wherever possible, try to create javascript objects that adhere to the following constraints so we can move those objects to a database if needed:
- No circular objects (an object cannot contain cycles)
- Only simple data in objects (strings, numbers, booleans, arrays, objects) instead of complex objects (classes, interfaces, class instances, etc.)
If integrating with a database, create a shared helper called /server/src/shared/helpers/mongo.ts
that contains all of the code defining each collection in the database. For each collection, define the typescript type for an entry that will be stored in said collection and put that type in /server/src/shared/types/stored
.
I'll explain dce-mango
through example. It is important that you follow the structure of this template. This includes formatting and headings.
First, import dependencies and the shared types associated with each collection:
// Import db
import { initMango, Collection } from 'dce-mango';
// Import shared types
import ActivitySubmission from '../types/stored/ActivitySubmission';
import ShareoutPost from '../types/stored/ShareoutPost';
import LiveMessage from '../types/stored/LiveMessage';
import LiveViewer from '../types/stored/LiveViewer';
import Migration from '../types/stored/Migration';
import WatchTrace from '../types/stored/WatchTrace';
Then, initialize dce-mango
, giving it a schemaVersion (start at 1 and increment every time you make changes to this file):
/*------------------------------------------------------------------------*/
/* ----------------------------- Initialize ----------------------------- */
/*------------------------------------------------------------------------*/
initMango({
schemaVersion: 12,
dbName: 'immersive-player-store',
});
Finally, define and export each collection. Define each collection by creating a new instance of the Collection
class. Add in the type of the entries in the collection, using the associated type that you imported: new Collection<MyEntryType>(...)
. The Collection
constructor takes two arguments: the first argument is the name of the collection, which should be named the same as the entry type: new Collection<MyEntryType>('MyEntryType', ...
. The second argument defines indexes, and takes the following parameters:
uniqueIndexKey
– a string that represents the key for the property to use as a unique index. If defined, each entry must have a unique value for this property. When inserting into a collection with a unique index, if an existing entry has a matching value for this key, the existing entry will be overwritten.
indexKeys
– a list of secondary non-unique keys that should be used to create other indexes. Here, simply provide a list of keys that will commonly be used for searching and querying. Don't get too carried away: for every key you add to this list, the database must maintain an index and must update the index when entries are added/modified/removed.
expireAfterSeconds
– a number of seconds that represents the minimum lifespan of entries in this collection. If not included, entries will not be automatically deleted. There is no guarantee that entries will be deleted immediately after they expire. Instead, regular cleanups occur and expired entries are deleted.
Remember to export each collection, as you see in the example:
/*------------------------------------------------------------------------*/
/* ----------------------------- Collections ---------------------------- */
/*------------------------------------------------------------------------*/
// Activity Submissions
export const activitySubmissionCollection = new Collection<ActivitySubmission>(
'ActivitySubmission',
{
uniqueIndexKey: 'id',
indexKeys: [
'courseId',
'videoId',
'isLearner',
'isAdmin',
],
},
);
// Shareout Post
export const shareoutPostCollection = new Collection<ShareoutPost>(
'ShareoutPost',
{
uniqueIndexKey: 'id',
indexKeys: [
'courseId',
'videoId',
'isLearner',
'isAdmin',
],
},
);
// Migrations
export const migrationCollection = new Collection<Migration>(
'Migration',
{
uniqueIndexKey: 'id',
indexKeys: [
'oldVideoId',
'newVideoId',
'migrationStatus',
],
},
);
// Live messages
export const liveMessageCollection = new Collection<LiveMessage>(
'LiveMessage',
{
uniqueIndexKey: 'id',
indexKeys: [
'courseId',
'videoId',
'timestamp',
'isLearner',
'isAdmin',
],
},
);
// Live viewers
export const liveViewerCollection = new Collection<LiveViewer>(
'LiveViewer',
{
uniqueIndexKey: 'id',
indexKeys: [
'courseId',
'videoId',
'timestamp',
'isLearner',
'isAdmin',
],
expireAfterSeconds: 90,
},
);
// Watch traces
export const watchTraceCollection = new Collection<WatchTrace>(
'WatchTrace',
{
uniqueIndexKey: 'id',
indexKeys: [
'courseId',
'videoId',
'isLearner',
'isAdmin',
],
},
);
Once deployed to AWS, dce-mango
will automatically connect to an auto-provisioned database, as long as the app is configured to have a dabase.
However, while developing your app and testing in a local dev environment, you'll need to have a test database. Ultimately, you'll need to "MONGO_URL" which you'll put in your /server/.env
file:
MONGO_URL=mongodb://some/mongo-database-url-here
There are two recommended ways of provisioning your test database:
This option is best if you want a simple, flexible database that has a UI, and is easy to share across multiple systems. Gabe recommends this for all EdTech Software Engineers that report to them because it'll be easier for Gabe to jump in and run your code with your current database state.
- Visit
cloud.mongodb.com
- Log in with your
g.harvard.edu
email - Create a new project
- Get a free "Shared" tier database and place it in AWS N. Virginia
- Create a Username and Password for your app
- Allow access from anywhere by adding an IP to your IP Access List: IP Address = 0.0.0.0/0 and Description = Anywhere
- Find the cluster (probably called "Cluster0") and click "Connect"
- Click "Connect your application"
- Copy down the "connection string" and replace "" with the username, replace "" with the password
- Paste the url into your
/server/.env
file as "MONGO_URL"
This option is best if you're a pro and want complete control over your cluster. Also consider this option if you're storing sensitive data.
Find a tutorial online on how to provision a local mongodb cluster. Create a database within that cluster. For the sake of this example, let's call the database "my-app". Then, a url to your local cluster in your /server/.env
file. It might look something like this:
MONGO_URL=mongodb://0.0.0.0:27017/my-app
Now that you've integrated with the database, you'll need to create, edit, delete, and query collections in the database. We'll walk through some asynchronous operations you can perform. Full documentation can be found via the typescript JSDoc embedded in the dce-mango
library. We'll show a few examples.
In each example, we will pretend that we're integrating with a "Users" collection that contains objects that look like this:
// Type (saved to /server/src/shared/types/stored/User.ts)
type User = {
// The user's unique id
id: string,
// The user's first name
userFirstName: string,
// The user's last name
userLastName: string,
// The user's email
userEmail: string,
// The user's current age
age: number,
// List of user's current addresses
addresses: {
type: ('home' | 'work' | 'other'),
streetAddress: number,
streetName: string,
streetSuffix: ('st' | 'dr' | 'av' | 'pl' | 'rd'),
cityName: string,
zipCode: number,
country: string,
}[],
};
// Example user:
const user: User = {
id: '1022',
userFirstName: 'Gabe',
userLastName: 'Abrams',
userEmail: '[email protected]',
age: 10,
addresses: [
{
type: 'home',
streetAddress: 13,
streetName: 'Axis',
streetSuffix: 'dr',
cityName: 'Cambridge',
zipCode: 02155,
country: 'US',
},
],
};
Before continuing, we need to describe a "query" object. This is used for searching the collection. You can think of a query as a partial object that is compared with every item in the collection. Any items that match the query will be returned/modified/deleted/etc. depending on the current operation. I'll explain with a few examples:
// Query that matches all users with a certain first name:
const query = {
userFirstName: 'Kino',
};
// Query that matches all users with a certain age:
const query = {
age: 14,
};
We support complex queries. You can google these, but generally, the way you use these is by putting an object in place of a value. Here are a few examples:
// Query that matches all users who are younger than 18
const query = {
age: { $lt: 18 },
};
// Query that matches all users who are 10 or 20
const query = {
age: { $in: [10, 20] },
};
To interact with the collection, simply import it at the top of your file:
import { userCollection } from '../../shared/helpers/mongo';
Each find command searches a collection for all matches to a query. If the returned array is empty, there are no matches to the query.
// Find all teenagers
const matches = await userCollection.find({
age: { $gte: 13, $lt: 20 },
});
To use this function, the collection must be uniquely indexed by a string "id" field. Simply pass in the id of the object and the property to increment.
// Increment a user's age by 1 year
await userCollection.increment('1022', 'age');
For this to work, the object must already exist in the collection. Instead of overwriting the object, you can use this operation to perform a partial update, only modifying certain parts of the object. Pass in a query that will be used to find the object to update, then pass in an object containing the updates to apply.
// Set the addresses of all users with the last name "Abrams"
await userCollection.updatePropValues(
{
userLastName: 'Abrams',
},
{
addresses: [
{
type: 'home',
streetAddress: 13,
streetName: 'Axis',
streetSuffix: 'dr',
cityName: 'Cambridge',
zipCode: 02155,
country: 'US',
},
],
},
);
If there's an existing array in an object, you can use this function to add one more item to that array. To use this function, the collection must be uniquely indexed by a string "id" field. Simply pass in the id of the object, the property that points to the array, and the object to insert.
// Add an address to a specific user's addresses array
await userCollection.push(
'1022',
'addresses',
{
type: 'home',
streetAddress: 13,
streetName: 'Axis',
streetSuffix: 'dr',
cityName: 'Cambridge',
zipCode: 02155,
country: 'US',
},
);
If there's an existing array in an object, you can use this function to filter items out of an array. To use this function, the collection must be uniquely indexed by a string "id" field. Pass an object that contains the id, arrayProp, and a compareProp (the property inside of each array object to compare) and the compareValue (the value to filter out).
// Remove all US addresses from a user's addresses array
await userCollection.filterOut({
id: '1022',
arrayProp: 'addresses',
compareProp: 'country',
compareValue: 'US',
});
This one's simple: it'll insert an item into the collection. If the collection is uniquely indexed and an object already exists, the insert will be converted to an "upsert" which replaces the existing object automatically.
// Add a user to the db
await userCollection.insert({
id: '1022',
userFirstName: 'Gabe',
userLastName: 'Abrams',
userEmail: '[email protected]',
age: 10,
addresses: [
{
type: 'home',
streetAddress: 13,
streetName: 'Axis',
streetSuffix: 'dr',
cityName: 'Cambridge',
zipCode: 02155,
country: 'US',
},
],
});
This one's simple: it'll delete the first object that matches the query.
// Delete a user
await userCollection.delete({
id: '1022',
});
We aim for high test coverage, but time is always limited, so we write tests in order of priority.
Here's our order of priority:
- Main interactive components + key features
- Edge cases
- Features that have a higher chance of breaking later on
- Everything else
There are some things that we do not test:
- Implementation details: these will change
- Specific look and feel (colors, etc.): these will change with themes
When writing tests, first write each assertion with the opposite type of test (if a button should be enabled, test if it's disabled) and make sure the test will fail. Writing tests that succeed is easy. Writing tests that fail when appropriate is very hard.
One way to identify all the tests that you need to write is to start with each prop and state variable:
- How does each prop or state variable impact the functionality/rendering/interactivity of the component?
- What kinds of special cases might arise for props/state variables? For example, negative numbers, empty strings, empty arrays, etc.
- How should the props/state react to errors?
- How does the component render, what content should it create based on each type of prop passed in?
Name your test the same as your component with a .test.tsx
filename:
MyComponent.tsx // Component
MyComponent.test.tsx // Test
First, import our testing library and the component to test:
// Import testing lib
import Raixa from 'raixa';
// Import component to test
import MyComponent from './MyComponent';
We use a custom-built wrapper for React Testing Library (RTL) that we call Raixa. Although we do not directly interact with RTL, for your own professional development, we recommend that you take some time to learn it. But for our purposes, if a testing functionality is not available in Raixa, we will add the functionality to Raixa instead of using RTL.
Create a test:
test(
'description of test',
async () => {
...
},
);
Describe your test. Here are some examples for an email form component:
- "Updates as user types into subject field"
- "Validates recipient address properly"
- "Sends the correct request to the server when user clicks 'send'"
Inside the test, render the component of interest, including appropriate props (note this is rendered to a hidden headless browser, there is no way to see this):
Raixa.render(
<MyComponent
prop1={value}
prop2="value"
/>,
);
Once you've rendered your component, use Raixa functions to test the component.
// Click the "start email button"
Raixa.click('.MyComponent-start-email-button');
// Type into the subject field
Raixa.typeInto('#MyComponent-email-subject-field', 'Test Email Subject');
// Test to make sure that the submit button is available
Raixa.assertExists('.MyComponent-submit-button');
If your interactable elements do not have classNames or ids, add them! Remember that if your component will ever be used in more than one place at once, use classNames. Otherwise, ids are fine.
If a test will take longer to execute, extend its timeout with a third argument:
test(
'description of test',
async () => {
...
},
10000, // Timeout in ms
});
If your component sends requests to the server, then you'll need to first stub requests (do this before Raixa.render
). For each request that your component will send, stub that request with stubServerEndpoint
from dce-reactkit
:
import { stubServerEndpoint } from 'dce-reactkit';
...
test(
'description of test',
async () => {
// Stub email form submission endpoint
stubServerEndpoint({
method: 'POST',
path: '/api/ttm/threads/102398/emails',
body: true,
});
// Render email form
Raixa.render(...
},
);
To stub a successful response from the server, use:
stubServerEndpoint({
method: <http method that the component will use>,
path: <path of the endpoint the component will send to>,
body: <fake response to simulate coming back from the server>,
});
To stub a failed response from the server, use:
stubServerEndpoint({
method: <http method that the component will use>,
path: <path of the endpoint the component will send to>,
errorMessage: <string error message that would come from the server>,
errorCode: <string error code that would come from the server>,
});
Example of a full test:
// Import testing lib
import Raixa from 'raixa';
// Import component to test
import EmailForm from './EmailForm';
test(
'Shows notice when recipient email is invalid',
async () => {
// Render the component
Raixa.render(
<EmailForm
sender="[email protected]"
onSent={() => {}}
/>,
);
// Type an invalid recipient
Raixa.typeInfo('.EmailForm-recipient-email-input-field', 'invalid.email@@.com');
// Make sure validation text shows up
Raixa.assertExists('.EmailForm-recipient-email-invalid-notice');
},
);
test(
'Allows email send when recipient email is valid',
async () => {
// Stub email form submission endpoint with a successful response
stubServerEndpoint({
method: 'POST',
path: '/api/ttm/threads/102398/emails',
body: true,
});
// Render the component
Raixa.render(
<EmailForm
sender="[email protected]"
onSent={() => {}}
/>,
);
// Type a valid recipient
Raixa.typeInfo('.EmailForm-recipient-email-input-field', '[email protected]');
// Make sure validation text is not visible
Raixa.assertAbsent('.EmailForm-recipient-email-invalid-notice');
// Click the "send" button
Raixa.click('.EmailForm-send-button');
// Make sure a success message shows up after some time
Raixa.waitForElementPresent('.EmailForm-send-successful-message');
},
);
test(
'Shows an error message when the server fails',
async () => {
// Stub email form submission endpoint with a successful response
stubServerEndpoint({
method: 'POST',
path: '/api/ttm/threads/102398/emails',
errorMessage: 'The message could not be sent',
errorCode: 'EM29',
});
// Render the component
Raixa.render(
<EmailForm
sender="[email protected]"
onSent={() => {}}
/>,
);
// Type a valid recipient
Raixa.typeInfo('.EmailForm-recipient-email-input-field', '[email protected]');
// Make sure validation text is not visible
Raixa.assertAbsent('.EmailForm-recipient-email-invalid-notice');
// Click the "send" button
Raixa.click('.EmailForm-send-button');
// Make sure the server's error is visible
Raixa.waitForElementPresent('.EmailForm-error-occurred');
},
);
You can stub multiple requests and Raixa will automatically know which to stub based on the method and path. If your component sends multiple requests to the same method and path combo, then intersperse stubServerEndpoint
throughout your test code, only stubbing right before your component sends the request.
Use npm run test:client
to start the jest test runner.
If test:client
is not set up yet, here's how you add that script:
- In the top-level
package.json
file, add a new script:"test:client": "cd client && npm run test"
- Then, in
client/package.json
, modify the "test" script to include the --runInBand flag:"test": "react-scripts test --runInBand"
For each helper function that we test, we aim for high test coverage, but time is always limited, so we write tests in order of priority.
Here's our order of priority:
- Usual inputs and outputs or side-effects
- Edge cases (unusual inputs or environment variables, for example)
- Features that have a higher chance of breaking later on
- Everything else
There are some things that we do not test:
- Implementation details: these will change
- Dependencies and libs: these are unit tests, so we do not focus on dependencies and libs
Name your test the same as your component with a .test.ts
filename:
myHelper.ts // Helper
myHelper.test.ts // Test
First, import the helper that you're going to test:
// Import helper to test
import myHelper from './myHelper';
All of our helper unit tests are built using vanilla Jest tests. We create our list of tests and each one becomes a call to the test
function:
Create a test:
test(
'description of test',
async () => {
...
},
);
Describe your test. Here are some examples for a helper function that divides two numbers:
- "Divides two positive, nonzero numbers"
- "Throws an error when one or both of the arguments are not numbers"
- "Throws an error if the denominator is zero"
- etc.
If a test will take longer to execute, extend its timeout with a third argument:
test(
'description of test',
async () => {
...
},
10000, // Timeout in ms
});
Simply use npm test
to run tests. If that doesn't work, ask Gabe to help set your project up with jest test running.
We use Katalon for all our end-to-end automated cross browser testing.
Visit Katalon.com, create an account, and then install the free version of the tool.
In Katalon's preferences panel, make the following changes:
Katalon > Git > Enable Git Integration
– Turn this off (uncheck the box)
Katalon > Test Case > Default Open View
– Set to "Script View"
General > Editors > Text Editors > Displayed Tab Width
– Set this to "2"
General > Editors > Text Editors > Insert Spaces for Tabs
– Turn this on (check the box)
Regularly update your web drivers:
- Launch Katalon
- Under
Tools > Update WebDrivers
, one by one, click each browser and update it
All of our tests will be organized in a GitHub repo that's separate from the code. Ask your project manager about cloning that repo.
Our test case files follow a strict folder structure: there should only be one top-level folder called "All Tests" inside of the "Test Cases" folder. Then, inside "All Tests", we create one folder for each feature of the project. Name your test cases in a descriptive manner. Example: "User can log in after password is reset"
Our tests are written in the Groovy
language, which looks like Java
.
Check out the Kaixa Docs for guides on how to write end-to-end tests, but it should feel just like writing tests with Raixa.
- Start a development copy of the app (server and/or client)
- Open the test case
- Next to the play button, click the dropdown and choose a browser
If you're looking for a module that does one of the operations below, use these libs. Thus, when bundling, we can save space by using common libs.
fast-clone
– for fast deep clones
papaparse
– for CSV parsing and generation
object-hash
– for hashing objects
In almost every case, let Gabe create React projects. That said, it's good to know the process:
- Create a git repo and clone it
- Initialize the project:
npm init
- Add a
.gitignore
- Initialize caccl app using
npm init caccl@latest
- Add eslint rules in each sub-project separately (client and server, for example):
npm init dce-eslint@latest
, remove react lines in/server/.eslintrc.js
- Add
private: true
flag inpackage.json
- Create a
server/
folder - Inside the
server/
folder, initialize the project:npm init
- Add a
.gitignore
- Add
private: true
flag inpackage.json
- If you have custom server env vars, add
**/.env
to your gitignore, installdotenv
on the server as a dev dependency, addimport 'dotenv/config';
to the top of your server index, and add a/server/.env
file where environment variables are listed one per line:NAME=value
- From the top-level directory, initialize react:
npx create-react-app --template typescript client
- Install bootstrap:
npm i --save bootstrap
- Import bootstrap in
index.tsx
:// Import bootstrap stylesheet import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/js/bootstrap.min';
- Install FontAwesome libs:
npm i --save @fortawesome/fontawesome-svg-core @fortawesome/free-regular-svg-icons @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome
- Install SCSS with
npm i --save-dev sass
- Remove eslint rules from
package.json
(they're included viadce-eslint
)
Add a script for copying types from the server to the client:
This script copies types from the server (/server/src/shared/types
) and puts them on the client (/client/src/shared/types
). This is extremely useful if types are shared between the server and the client, or if a database is used.
"scripts": {
"copy-server-types": "rm -rf ./client/src/shared/types/from-server; cp -r ./server/src/shared/types ./client/src/shared/types/from-server"
},
Install dce-dev-wizard
into the project: npm i --save-dev dce-dev-wizard
.
Add a dceConfig.json
file with deployment information. The name
is a human-readable deployment name, app
is the aws name of the deployment, and profile
is the aws profile. Example:
{
"deployments": [
{
"name": "Stage",
"app": "my-app-stage",
"profile": "stage"
},
{
"name": "Prod",
"app": "my-app-prod",
"profile": "prod"
}
]
}
"scripts": {
"dev-wizard": "./node_modules/.bin/dce-dev-wizard"
}
Once you've created the project, you need to separately add typescript support.
First, move all code into a src/
folder and make sure your project has no top-level lib/
folder.
Add typescript to the project:
- Install typescript with
npm i --save-dev typescript
- In
package.json
, updatemain
to./lib/index.js
- In
package.json
, updatetypes
to./lib/index.d.ts
- Add a build script:
tsc --project ./tsconfig.json
Add a tsconfig.json
file to the top-level directory of the project:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"noEmitOnError": true,
"removeComments": false,
"declaration": true,
"sourceMap": true,
"target": "es5",
"lib": ["DOM", "ES2015"],
"outDir": "./lib"
},
"include": [
"./src"
],
"ts-node": {
"files": true
}
}
You can customize the tsconfig.json
. This config is designed to target es5 while supporting modern standards.
If your project has non-typescript files that are required in the project, update your build script:
- Install new dependencies
npm i --save-dev rimraf copyfiles
- Update your build script:
rimraf lib/ && tsc --project ./tsconfig.json && copyfiles -u 1 src/**/*.ejs src/**/*.jpg lib/
Component libraries are npm modules that contain React components that are intended for reuse. If you find yourself reusing a component across multiple projects, you might want to consider putting that component into a shared component library.
Creating a component library can be really complex and finnicky, so we've simplified our process and made some compromises. Please follow this guide, including all simplifications and compromises.
When naming your component library, include dce
or harvard
in the project name unless it is a project that will be used across multiple universities. We don't want to be part of the npm clutter problem, so don't reserve generic names for our internal libs if you don't need to. For example, if we're creating a support library that is used for managing dates and times with respect to Cambridge, MA, don't snag the date-manager
project name. Instead, call your library dce-date-manager
or something.
Before components can be added to a component library, we require that style be translated into vanilla css (not scss) and copied inline into a new section at the top of the component .tsx
file:
/*----------------------------------------*/
/* ---------------- Style --------------- */
/*----------------------------------------*/
const style = `
.MyComponent-container {
...
}
`;
Then, in the final return
in the render function, add the style inline:
return (
<div className="MyComponent-container">
{/* Inline Style */}
<style>
{style}
</style>
...
</div>
);
Now, we'll create an npm package that we will be publishing as open source to npm. For consistency and maintenance, Gabe will be the one who publishes and maintains these packages on npm.
First, create a new repo on GitHub, set it up using the Node
gitignore template, write a short description of the project (and copy that description to the clipboard), and add the MIT
license. Then, clone that repo to your computer.
Next, initialize the npm project using npm init
. When prompted for a description, paste the description from your clipboard. When prompted for an author, use Gabe Abrams <[email protected]>
, and when prompted for a license, use "MIT".
Also, add eslint rules to the project:
npm init dce-eslint@latest
Install dev dependencies:
npm i --save-dev rollup rollup-plugin-dts rollup-plugin-sourcemaps typescript @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-typescript @types/react
Install peer dependencies, modifying for the proper versions:
// Add to package.json:
"peerDependencies": {
"@fortawesome/free-regular-svg-icons": "^6.x.x",
"@fortawesome/free-solid-svg-icons": "^6.x.x",
"@fortawesome/react-fontawesome": "^0.x.x",
"bootstrap": "^5.x.x",
"react": "^x.x.x"
},
Then run npm i
to install the appropriate dependencies.
Add a build script to your package.json
:
"scripts": {
...
"build": "rm -rf dist && rollup -c"
},
Modify/add the following lines to your package.json
:
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
Add a rollup.config.js
file to the root project directory:
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';
import sourcemaps from 'rollup-plugin-sourcemaps';
const packageJson = require('./package.json');
export default [
{
input: 'src/index.ts',
output: [
{
file: packageJson.main,
format: 'cjs',
sourcemap: true,
},
{
file: packageJson.module,
format: 'esm',
sourcemap: true,
},
],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
sourcemaps(),
],
external: [
...Object.keys(packageJson.dependencies || {}),
...Object.keys(packageJson.peerDependencies || {}),
],
},
{
input: 'dist/esm/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
plugins: [dts()],
external: [
...Object.keys(packageJson.dependencies || {}),
...Object.keys(packageJson.peerDependencies || {}),
],
},
];
Add a tsconfig.json
file to the root project directory:
{
"compilerOptions": {
"target": "es2016",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true,
"rootDir": "src"
},
"include": [
"./src/**/*"
]
}
Create a /src
folder and a /src/index.ts
file that will serve as the entrypoint.
Add all components to a /src/components
folder and follow all other normal rules for file and folder structure (naming conventions, shared folders, etc.).
In the /src/index.ts
file, import all components, helpers, types, etc. that you want to be available to users of your package and then export them in one object:
// Components
import MyFirstComponent from './components/MyFirstComponent';
import MySecondComponent from './components/MyFirstComponent';
// Helpers
import myHelperFunction from './helpers/myHelperFunction';
// Types
import MyFirstType from './shared/types/MyFirstType';
import MySecondType from './shared/types/MySecondType';
// Export
export {
// Components
MyFirstComponent,
MySecondComponent
// Helpers
myHelperFunction,
// Types
MyFirstType,
MySecondType,
};
One of the easiest ways to test is to create a test React app, copy your components into the app and try to use them. This is a good place to start, but won't get you all the way there, so check out npm link
functionality and ask Gabe for specific testing strategies that will work for you.
Build your component lib by running npm run build
. If no errors occur, check the /dist
folder for built contents.
Commit and push your code and ask Gabe to publish the library.
Only Gabe publishes packages to npmSupport libraries contain helpful, reusable code that is used across multiple projects. These support libraries cannot contain React code. If they do, consider a Component Library (see the previous section).
Creating a support library is extremely complicated and complex, so we've created this guide and need you to stick to it. What might seem to be compromises are probably careful decisions that were made to improve the build process or robustness of the library in other ways.
When naming your support library, include dce
or harvard
in the project name unless it is a project that will be used across multiple universities. We don't want to be part of the npm clutter problem, so don't reserve generic names for our internal libs if you don't need to. For example, if we're creating a support library that is used for managing dates and times with respect to Cambridge, MA, don't snag the date-manager
project name. Instead, call your library dce-date-manager
or something.
Any code that you want to place into a shared library must be 100% typescript files (.ts
). We do not currently support assets or other types of files.
Now, we'll create an npm package that we will be publishing as open source to npm. For consistency and maintenance, Gabe will be the one who publishes and maintains these packages on npm.
First, create a new repo on GitHub, set it up using the Node
gitignore template, write a short description of the project (and copy that description to the clipboard), and add the MIT
license. Then, clone that repo to your computer.
Next, initialize the npm project using npm init
. When prompted for a description, paste the description from your clipboard. When prompted for an author, use Gabe Abrams <[email protected]>
, and when prompted for a license, use "MIT".
Also, add eslint rules to the project:
npm init dce-eslint@latest
Install dev dependencies:
npm i --save-dev @types/node typescript
Add a build script to your package.json
:
"scripts": {
...
"build": "tsc --project ./tsconfig.json"
},
Add/modify the following package.json
lines:
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
Add the following tsconfig.json
file to the top-level of your project:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"noEmitOnError": true,
"removeComments": false,
"declaration": true,
"sourceMap": true,
"target": "es5",
"outDir": "./lib"
},
"include": [
"./src"
],
"ts-node": {
"files": true
}
}
Add all code to a /src
folder and follow all other normal rules for file and folder structure (naming conventions, shared folders, etc.).
In the /src/index.ts
file, import all functions, types, etc. that you want to be available to users of your package and then export them in one object:
// Functions
import myFirstFunction from './helpers/myFirstFunction';
import mySecondFunction from './helpers/mySecondFunction';
// Types
import MyFirstType from './shared/types/MyFirstType';
import MySecondType from './shared/types/MySecondType';
// Export
export {
// Functions
myFirstFunction,
mySecondFunction,
// Types
MyFirstType,
MySecondType,
};
To test your project, create a test npm project somewhere else on your machine, then follow these instructions for testing via npm link
:
First, build your support library using npm run build
.
Then, link your support library using npm link
.
Finally, in your test project, run npm link <package-name>
where <package-name>
is the name of your support library.
Repeat these steps as necessary.
Build your component lib by running npm run build
. If no errors occur, check the /dist
folder for built contents.
Commit and push your code and ask Gabe to publish the library.
Only Gabe publishes packages to npm