The goal of this document is to present a set of guidelines, conventions and best practices to develop frontend code for CZID. It is an ongoing effort as opposed to a thorough document. These guidelines should be enforced for any new PRs.
When in doubt, follow the Airbnb JS Style Guide.
The following norms are particularly relevant to czid-web
.
- Use named exports rather than default exports
- The reason is because if you use named exports, you can always find all instances of a component's use by searching for that component's name. With default exports, the importing file can rename that component to anything, making it much harder to find where components are used. This matters when modifying code so that future developers can understand the impact changes to a component has throughout the application, or when a developer intends to remove old components.
- yes: export const SomeComponent…
- no: const SomeComponent… export default SomeComponent
- Use functional components over class components
- Frontend variables should be camelCase and should share their names with the corresponding ruby snake_case variables
- Component imports (lint-rule enforced) Imported components themselves should be alphabetical within each group. For import file order:
- libs
- absolute paths
- relative paths
- Line length maximum 120 characters (lint-rule enforced)
- Type definitions either at the top of the file or in a separate types.ts file in the component’s directory
- Identifiers (variables, objects and methods) should be named using
camelCase
notation starting with a lowercase letter - Classes and filenames are named using
CamelCase
notation starting with an uppercase letter - Use descriptive names. Longer descriptive names are preferred to short cryptic ones, e.g.
convertThresholdedFiltersToJson
vsconvThresh
(great if you can be both short and descriptive...) - Names for specific purposes:
- Event handlers: should be prefixed with
handle...
and, e.g.handleThresholdApplyClick
- Event hooks: should be prefixed with
on...
e.g.onApplyClick
- Example:
<Button onClick={this.handleButtonClick} />
onClick
is a hook (kind of like a parameter), which allows a handler likehandlerButtonClick
to be supplied by a client.
- Example:
- Boolean methods: should be prefixed with a verb
is
orhas
, e.g.isThresholdValid
- Event handlers: should be prefixed with
lodash/fp
provides nice immutable utility functions (see React section below on immutability). We use lodash/fp
exclusively (no lodash
) in order to prevent confusion between the two variants.
When possible, please use native/built-in functions over lodash functions. For example, Array.map is a built in JS function, so you would not need to use the lodash map unless you were working with an object. Native functions tend to be more readable and performant than lodash. Additionally, avoid using lodash.get
. JS now has optional chaining, which enables us to, eg, call functions or access object values only if they exist and continue error free if they do not. Optional chaining is more readable and performant than using loadsh get
.
The basic way to build a React component is to follow the steps below. We use hygen code generator to set up this boilerplate - see below.
For illustration, we will create a view component named Foo
in .../views/Foo
Foo
├──Foo.tsx
├──index.tsx
├──foo.scss
├──components
├──├──Bar
├──├──├──...
-
Create a component folder in the directory it belongs to. E.g., a view component lives in
./app/assets/src/components/views/*
. -
Create a new file
.../views/Foo/Foo.tsx
- this is where the implementation details ofFoo
should live. -
Create a new file
.../views/Foo/index.tsx
- this file simply imports Foo and exports it. This is how the call sites import the component. -
Create
./src/views/Foo/foo.scss
to host all styles you use in.../views/Foo/Foo.tsx
. If your component name has multiple words likeFooComponent
then the scss file will be snake casefoo_component.scss
. -
Create
.../views/Foo/components/
directory to host all sub-components you use in.../views/Foo/Foo.tsx
. For example, if you use component<Bar />
in<Foo>
, you can create a directory.../views/Foo/components/Bar
to encapsulateBar
component's implementation details. -
And if
Bar
component usesBaz
, we can create.../views/Foo/components/Bar/components/Baz
to encapsulateBaz
component's implementation details
As you can see, a component is typically made of other components and/or sub-components, so we can use the basic component file structure illustrated above to recursively build out a component at any level. One benefit of this fractal is that the component interface and boundaries are well defined, so extracting a component to a different directory is as easy as cut and paste
We have a component template defined with hygen. Templates live in any _templates
directory. Feel free to add more templates.
To use
$ brew tap jondot/tap
$ brew install hygen
To generate a new component skeleton in our project:
- Copy the path where you want the component to live
- Run
$ hygen component new ComponentName
It will prompt you for the path and then create the component skeleton for you.
There is also a vscode extension that will run the generator:
- Install the hygen extension
- Open command pallete (Shift+Cmd+P) and search for 'hygen'.
- Select the new component template
- Paste in the path when prompted
- One component per file
- Please avoid using
renderChildComopnent
type functions to create building blocks of code within a component. If you need one, choose to create a new child component instead. When you have multiple components in one file where most are rendered using anonymous functions, the subcomponents are not named independently from the parent, and all the state lives together at the parent level. This removes the ability to efficiently troubleshoot React components using React dev tools.
- Please avoid using
- Each component gets its own scss file. Style reuse should be limited to avoid bugs when updating or deleting styles.
- Prefer stateless functional components over class-based components for new components. Try
useEffect
,useState
, and other React Hooks if you need lifecycle methods. - Avoid monolithic components. Break complex components up into smaller units.
- Each component should have a single use.
- If the component has 3 or more variations, it should be split into multiple components
- This makes code easier to reason about and encourages reusability.
- Components should usually be no more than 250 lines.
- Pull business logic out of the component and into pure utility functions whenever possible. Better readability, and easier to unit-test further down the line. Also keeps the component slim and focused on the core business logic and rendering.
- Prefer inline JSX over render functions. This will be difficult to do for existing giant components, but we should lean toward this going forward. If you find yourself making lots of render components, that might be a good sign that you need to break your components into several smaller components.
- yes:
return ( <> <Table /> </> )
- no:
const renderTable = () => <Table /> return ( <> {renderTable()} </> )
- yes:
- Use semantic HTML elements (i.e. ul/li for lists, not divs for everything). This is important for accessibility.
- Use camelCase class names
- Use SDS components, mixins, etc whenever possible
- Prefer full class names over nested css
- yes:
.header { @include font-header-xs; } .headerIcon { margin-right: $sds-spaces-xs; }
- no:
.header { @include font-header-xs; .icon { margin-right: $sds-spaces-xs; } }
- yes:
- Include the type of the component as a suffix of the name. For instance:
PrimaryButton
SamplesView
HeatmapVisualization
API calls
Put API calls in /api
. Use and build upon the provided api methods like postWithCSRF
and get
instead of directly using axios
. This layer of indirection allows us to do things like standardized error handling and converting snake case to camel case for all our API endpoints further down the line.
Start putting fetch methods like fetchSampleMetadata
in /api
, so that it's easy to see all the back-end endpoints the front-end is using.
The main index.js
file is quite large, so prefer putting methods in a more specific file in the /api
folder if possible.
We have some accessibility rules defined in .eslintrc-a11y.json
(you can run ./node_modules/.bin/eslint app/assets/src -c .eslintrc-a11y.json --ext .js,.jsx
).
- Try adding an
onKeyDown
with the same handler as theonClick
. When the element is focused, this gives non-mouse users a keyboard option.- Example:
onClick={this.handleClick} onKeyDown={this.handleClick}
- Example:
- Use the correct semantic HTML instead!
- You can add an ARIA role, although there may be other warnings resulting from the rule addition.
-
Don't Mutate Objects: Always create a new object/array when modifying component state, instead of modifying the original object (even if you call
setState
afterward). This allows React to figure out if props have changed by via shallow comparison, and allows for future rendering optimizations usingReact.PureComponent
.lodash/fp
functions are immutable and do this by default. -
Use Arrow Functions: Inside React components, define instance methods with arrow function syntax
foo = () => { ... }
. This removes the need forthis.foo = this.foo.bind(this)
. -
Be Conscious of setState: Be very careful of the the asynchronous nature of
this.setState
, as it can lead to subtle bugs. TheprevState
version (this.setState(prevState => {}
) can work as a quick fix, but overuse ofprevState
and wrapping entire functions inthis.setState
is discouraged. You can often reorganize your functions to remove the bug (and also clarify the different code flows).
This makes it easier to add/remove arguments, reason about long lists of arguments, and work with default values. It also improves readability when calling the function.
const sampleErrorInfo = (sample, pipelineRun = {}, error = {}) => { ...
const sampleErrorInfo = ({ sample, pipelineRun = {}, error = {} }) => { ...
- Single ternaries are encouraged for simple expressions.
- Double ternaries in JSX can be used when appropriate. Please use parantheses.
- Triple ternaries are heavily discouraged.
filtersLoaded ? Array.isArray(filters) ? filters.length : !filters ? 0 : 1 : null
D3 code should be created in plain JS (no JSX) and should be placed in separate files for React code.
Icons should follow the Design System and be stored in ~/components/ui/icons/
as a .jsx
file rendering an <svg>
.
- Get the SVG asset from the designer.
- Copy the
<svg>
tag into a skeleton React functional component. - Delete extraneous HTML artifacts:
<title>
,id=
,version
,xmlns
,xmlns:xlink
. - Change attributes such as
stroke-width
->strokeWidth
orfill-rule
->fillRule
. - For
width
,height
, andviewBox
sizes, use one of the exported sizes in_elements.scss
whenever possible. (Ex:viewBox={`0 0 ${cs.imgM} ${cs.imgM}`}
) - If there is just one nested group, remove it and just copy the
fillRule
andfill
, if defined, to the parent<svg>
. - If the icon needs to change color via a
className
prop, set a defaultcolor
in the<svg>
tag (e.g.<svg className={className} color="#3867FA">
).- Next, set
fill="currentColor"
on any child<g>
tags with color. This way acolor
coming from theclassName
will override the icon default.
- Next, set
- Add the icon to
~ui/icons/index.js
. - Icons should be imported in one statement, e.g.
import { IconAlert, IconLoading } from "~ui/icons"
.
- We use one scss file per React component, which we place in the same directory as the React component.
- If you need to create related components with common styling (like variations of a component), create a base component that holds all relevant common characterisitcs. The other components will compose this base component. For instance, we defined
PrimaryButton
andSecondaryButton
render aButton
and common properties are defined inbutton.scss
(even if Button does not do much more than rendering the equivalentButton
class fromsemantic-ui
).
- SCSS Filenames should use the
snake_case
notation by splitting words with underscores - Selectors should use the
camelCase
notation.- The choice for this notation is driven by our decision to use CSS Modules (see next bullet). Legacy code uses
dash-case
typically.
- The choice for this notation is driven by our decision to use CSS Modules (see next bullet). Legacy code uses
- We use CSS Modules to modularize our CSS. This allows us to use short class names without fear of collisions with 3rd party libraries or classes from other components.
Be aware of shared style files, particularly _color.scss
. Only use color hex values from _color.scss
and when you need a new color, add it to the file.
- Don't use
materialize
: We got rid of it. - Flexbox can be very helpful for centering and aligning elements, as well as styling elements to fill up available space. Something to be aware of.
- Use camelCase for properties in JSON objects (even if writing in Ruby).
When in doubt, follow the Airbnb Ruby Style Guide.
The following rules are particularly relevant to czid-web
.
This makes it easier to add/remove arguments, reason about long lists of arguments, and work with default values. It also improves readability when calling the function.
def self.samples_going_public_in_period(range, user = nil, project = nil)
Use: samples_going_public_in_period([start, start + ahead.days], nil, Project.find(params[:projectId])
def self.samples_going_public_in_period(range:, user: nil, project: nil)
Use: samples_going_public_in_period(range: [start, start + ahead.days], project: Project.find(params[:projectId])
- What are Strong Parameters?
- Use Strong Params whenever possible for security reasons, even if you're passing params to another function.
- Avoid mass assignment whenever possible, even if using Strong Params. Extract specific parameter values instead.
- Use multiple sets of Strong Params if needed for complex controllers.
- This will help us enable Strong Params in Strict Mode and build towards API standardization in the future.
sample = Sample.new(sample_attributes)
@sample.update(sample_params)
def person_params
params.require(:person).permit(:name, :age)
end
- Responses using
.as_json
or blanket object serialization are permissible for simple objects. - Restrict to default or specifically defined keys (ex: including
only: SAMPLE_DEFAULT_FIELDS
). - Avoid blanket serialization for objects such as
Samples
with many fields that are hard to track. - This is a short-term guideline that will help us consolidate responses for API standardization.
render json: @sample.as_json
render json: { min_contig_reads: min_contig_reads, contig_counts: contig_counts }
render json: @sample.as_json(only: SAMPLE_DEFAULT_FIELDS)
- All controller code should refer to an object scope instead of the blanket ActiveRecord object. This ensures that at least some level of access control is checked.
- Even if the model has no scoping, the scope could be as simple as:
def self.viewable(user)
all
end
- See also: Brakeman warning
samples = Sample.where(id: params[:sampleIds])
projects = Project.find(id: params[:project_id])
samples = current_power.samples.where(id: params[:sampleIds])
samples = samples_scope.where(id: params[:sampleIds])
projects = current_power.projects.find(id: params[:project_id])
- Single ternaries are encouraged for simple expressions.
- Double or triple ternaries in Ruby are discouraged.
loc.is_a?(Array) ? (loc[0] && loc[0].present? ? loc[0] : loc[1]) : loc
if loc.is_a?(Array)
loc[0] && loc[0].present? ? loc[0] : loc[1]
else
loc
end
- We have many legacy "helper" modules in
app/helpers
, but that folder is actually meant for Rails view helpers (which is not relevant in React). Going forward, consider putting utility code inapp/lib
or feature code in the appropriate model or controller instead. - See here for a general "Where Do I Put My Code In Rails" guide.
- Services should raise exceptions to communicate errors and allow the caller to handle as desired.
- Bubble up truly unexpected errors to the highest level possible and handle generically.
- Known or expected application errors should raise a custom exception. These can be handled at the controller level or where appropriate.
class NoDepthDataError < StandardError
def initialize(workflow_run)
super("No depth data available for workflow_run #{workflow_run.id}.")
end
end
As with the JSON rule above, try to use camelCase for new Rails controller parameter names to standardize. If the value gets saved in a Ruby variable, the Ruby variable name should still be snake_case.
params.permit(:name, :project_id, :tax_id, :superkingdom_name, { additional_reference_accession_ids: [], pipeline_run_ids: [] })
params.permit(:name, :projectId, :taxId, :superkingdomName, { additionalReferenceAccessionIds: [], pipelineRunIds: [] })
- You do not need to test private methods but make sure everything at a higher level is covered. Try to make sure the entire interface is covered (there will be some special cases where testing private methods will be useful). When reviewing PRs, pay extra attention to ensure the interface is well tested. Read Unit Testing: Interface vs. Implementation for more info!
- When in doubt on testing pattern, follow the Better Specs guide.
- When deploying new features or code, consider if a feature flag is required or desired, to allow the flexibility to turn code on and off.
- CZ ID uses a lightweight custom feature flag system that is documented in the github wiki. Further information about creating and using feature flags can be found in the wiki.