-
Notifications
You must be signed in to change notification settings - Fork 18
Frontend ‐ Onboarding
The napari hub frontend is a React based web application written in primarily TypeScript. This guide will provide all the information you'll need to contribute to the napari hub frontend.
The frontend is built with a lot of different technologies. Below you'll find a list of links you can check out to get familiar with our tech stack and general web development:
- MDN Getting Started with the Web
- web.dev Learn CSS!
- MDN JavaScript Guide
- TypeScript Handbook
- React.js Tutorial
- Next.js Foundations
- Introduction to Node.js
While this is a lot of reading, as long as you're familiar with HTML, CSS, and JavaScript, you should be fine! 😃
You're free to use whatever editor or IDE you'd like, but we recommend VSCode. It's fast, free, and has a bunch of extensions that are useful for web development.
A list of recommended extensions are included in
.vscode/extensions.json, and are viewable in
VScode using the @recommended
filter:
VScode Screenshot
Setting up the frontend dev environment is documented in the README, so please follow the README steps to setup your environment.
Running the following command will start the napari hub in development mode:
yarn dev
In development mode, any changes to the source code will immediately hot refresh the page so that you can view your changes instantly. If you're modifying something related to state management, you may need to refresh the page manually for the changes to pick up.
To run the napari hub in production mode, you'll need to first build the frontend and run the start command:
yarn build
yarn start
Running the server in production mode is great for testing issues that happen only in production.
For development mode, the frontend will automatically use a mock API server for backend data. This is incredibly useful for when backend features are not yet implemented and being built out in parallel with the frontend.
However, if you'd like the frontend to point to a different URL for the API (for
example staging or production), you can use the API_URL
and MOCK_SERVER
environment variables.
For the staging API, you can use:
# Development mode
MOCK_SERVER=false API_URL=https://api.staging.napari-hub.org yarn dev
# Production Mode
yarn build
API_URL=https://api.staging.napari-hub.org yarn start
And for the production API, you can use:
# Development mode
MOCK_SERVER=false API_URL=https://api.napari-hub.org yarn dev
# Production Mode
yarn build
API_URL=https://api.napari-hub.org yarn start
The src/constants
directory is used for shared constants. If the module is
used for a specific feature or component, you can colocate the module with the
feature / component.
Components are stored in src/components
. Each component should be named using PascalCase
, and should have the following file structure:
src/components/NewComponent
├── index.ts // File for declaring exports
├── NewComponent.tsx // Main component
├── NewComponent.module.scss // [Optional] Styling
├── NewComponent.test.tsx // [Optional] Testing
├── NestedComponent.tsx // [Optional] Used by main component
├── types.ts // [Optional] Colocated types file
└── utils.ts // [Optional] Colocated utils file
Every component module directory should at least include the main component and an index.ts
file that exports it:
export * from './NewComponent'
If a component is used by only the main component, it might make more sense to
colocate it in the same module directory. However, if you find yourself using it
in other places, it should be moved to src/components
. The same reasoning can
be applied fo utils.ts
and types.ts
and their respective shared directories
src/utils
and src/types
.
Copy strings are stored in the i18n
directory. The file structure should look like:
i18n/
└── en
├── about.mdx
├── common.json
├── contact.mdx
├── faq.mdx
├── footer.json
├── homePage.json
├── pageTitles.json
├── pluginData.json
├── pluginPage.json
├── preview.json
└── privacy.mdx
The MDX
files are markdown content used for the static pages like /faq
,
/about
, and /privacy
.
The JSON files contain strings that are used for titles and texts used throughout the napari hub. Using this framework, it's also possible to add support for different languages and take advantage of the interpolation functionality for i18n.
Each language directory should include the same files with the same names.
Any shared custom hooks can be stored in src/hooks
. Each file should have
start with use
and be named in camel case. For example, we could use the
following name for a login hook:
touch src/hooks/useLogin.ts
The src/pages
directory is used to store pages on the napari hub. Every
possible URL at https://napari-hub.org
has a corresponding page component
located in src/pages
.
Check out the Next.js Routing docs to get a better understanding of how the file structure works.
src/pages
├── 404.tsx
├── 500.tsx
├── [...parts].tsx
├── _app.tsx
├── _document.tsx
├── api
│ └── health.ts
├── derp.tsx
├── index.tsx
├── plugins
│ └── [name].tsx
├── preview.tsx
├── robots.txt.ts
└── sitemap.xml.ts
MDX Pages are special because instead of creating a file in src/pages
, you
need to instead create a file in i18n/<lang>
.
The src/store
directory contains code for shared UI state. This is used for
sharing data across multiple components.
src/store
├── loading.ts
├── preview.ts
└── search
├── constants.ts
├── context.tsx
├── engines.ts
├── filter.store.test.ts
├── filter.store.ts
├── queryParameters.ts
├── results.store.ts
├── search.store.ts
├── search.types.ts
├── sorters.test.ts
├── sorters.ts
├── types.ts
└── usePlausibleSearchEvents.ts
The src/types
directory is used for shared types. If the module is used for a
specific feature or component, you can colocate the module with the feature /
component.
src/types
└── i18n.ts
The src/utils
directory is used for shared utilities. If the module are used
for a specific feature or component, you can colocate the module with the
feature / component.
src/utils
├── featureFlags.ts
├── format.test.ts
├── format.ts
├── index.ts
├── logger.ts
├── next.test.ts
├── next.ts
├── page.ts
├── performance.ts
├── react.test.tsx
├── react.ts
├── repo.ts
├── search.ts
├── styles.scss
└── url.ts
Unit / Integration tests are usually stored next to the file they're testing, and ends with the .test.ts
or .test.tsx
. For example:
src/components/Example
├── Example.test.tsx
├── Example.tsx
├── utils.test.ts
└── utils.ts
E2E tests are stored in the tests/
directory, and also use the .test.ts
extension:
tests/
├── pages
│ ├── home
│ │ ├── filter.test.ts
│ │ ├── pagination.test.ts
│ │ ├── search.test.ts
│ │ ├── sort.test.ts
│ │ └── utils.ts
│ └── plugin.test.ts
└── tsconfig.json
Components are the building blocks of a React.js application. Components are modular units of code that include the rendering logic for creating a UI. They can wrap other components, allowing us to build entire UIs as a single, large component composed of smaller components. Each page on the napari hub frontend is a single, giant component composed of smaller components for each UI element on the page.
Below is a screenshot of the home page, with boxes outlining where each component is. Notice that some boxes are composed of other boxes, indicating that these components are built using smaller components.
Component Outline
JSX is a Domain-Specific Language (DSL) for writing HTML-like syntax in JavaScript, allowing us to tightly couple rendering logic with other UI logic like event handlers, state changes, and data processing.
For example, a hello world component with JSX would look something like this:
function Hello({ name }) {
return <p>Hello, {name}!</p>
}
const hello = <Hello name="World" />
Rendering hello
to the DOM will result in the following HTML:
<p>Hello, World!</p>
Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class.
Hooks are a way for us to create reusable functions of UI logic. The most common example is the useState()
hook which is used for storing rendering state, but you can also build custom hooks for things like authentication or managing query parameters.
For example, one custom hook we may want is an auth hook that checks if the current visitor is authenticated:
src/hooks/useAuth.ts
import { useQuery } from 'react-query'
interface AuthResponseData {
loggedIn: boolean
}
function fetchAuth(token: string): Promise<AuthResponseData> {
// fetch auth data using token
}
function getUserToken(): string {
// get user token somehow
}
export function useAuth() {
const token = getUserToken()
const { data, ...rest } = useQuery(['auth', token], () => fetchAuth(token))
return { isLoggedIn: data.loggedIn, ...rest }
}
src/components/Auth/Auth.tsx
export function Auth() {
const { isLoggedIn } = useLogin()
if (!isLoggedIn) {
return <p>Pls login</p>
}
return <p>You are logged in :)</p>
}
Components can be styled in two ways: Tailwind CSS or SCSS.
Prefer Tailwind CSS whenever possible. If it's not possible in Tailwind, use SCSS.
Tailwind CSS is a set of utility classes that can be used for styling HTML elements. Each CSS class represents a specific CSS property and value, and can be used to build complex styled components.
For example, the following component will render blue text in a red box:
export default function Derp() {
return <p className="bg-red-500 text-blue-500 p-5">Hello, World!</p>
}
The class name bg-pink-500 text-blue-500 p-5
will generate the following CSS:
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
}
.bg-blue-500 {
--tw-text-opacity: 1;
color: rgba(59, 130, 246, var(--tw-text-opacity));
}
.p-5 {
padding: 1.25rem;
}
The main benefit of using Tailwind CSS is that the above classes are generated only once, meaning that every component that uses the above CSS classes will be reusing the same one.
This is extremely powerful because it means we don't have to worry about writing new CSS classes with duplicate styling, we just reuse the utility classes. This also helps with reducing the amount of CSS we send to the user's browser.
If Tailwind CSS does not solve a particular use case, we can use SCSS for styling. SCSS is a syntax extension built on top of CSS, adding things like variables, if-statements, for-loops, dictionaries, and more.
New SCSS files can be created using the .scss
extension:
touch src/styles.scss
To use this SCSS file, you'll need to add it to the _app.tsx
file:
src/pages/_app.tsx
import '@/styles.scss'
Plain SCSS is still scoped globally, so we recommend using SCSS modules at a per-component level whenever possible.
CSS Modules is an approach for generating CSS classes that are scoped and isolated for a particular component. They're especially useful so that we don't have to worry about polluting the CSS global scope.
SCSS can also be used with CSS Modules. To create a new SCSS module, use the .module.scss
extension:
touch src/components/NewComponent/NewComponent.module.scss
Important: SCSS Modules should be be scoped to a particular component and follow the above file name format.
When writing SCSS in an SCSS Module, you don't have to worry about polluting the
global CSS scope. The names are automatically generated into a hash that is
unique for the component. For example, .newComponent
may become .AJifajia
.
To use SCSS modules, you'll need to import it into your component and pass it into the className
prop:
src/components/NewComponent/NewComponent.module.scss
.newComponent {
background: red;
}
src/components/NewComponent/NewComponent.tsx
import styles from './NewComponent.module.scss'
export function NewComponent() {
return <div className={styles.newComponent}>...</div>
}
UI state is data that changes over time and affects the HTML being rendered to the page. Examples include whether a popup is open or closed, the active tab in a group of 3 tabs, or the current search results visible given the current state of plugin filters.
There are two types of state: local and global state. Local state is data that is used by a specific component or hook, while global state is data that is shared between multiple components.
Try to keep state local whenever possible to keep things simple. However, if you find yourself needing to share and modify state from multiple components, then it's time to move it to global state.
There is, however, a gray area between global and local state called context. It's primarily useful for prop drilling, but can be mistaken as a global state solution.
For local state, you can use React's useState() hook:
import { useState } from 'react'
function Example() {
const [count, setCount] = useState(0)
function increment() {
setCount(prev => prev + 1)
}
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
)
}
For global state, we use the Valtio package. You can declare state using the proxy
function and read state using the useSnapshot()
function:
src/store/count
import { proxy } from 'valtio'
export const countStore = proxy({
count: 0,
})
src/components/Count/CountIncrementButton.tsx
import { countStore } from '@/store/count'
export function CountIncrementButton() {
const snap = useSnapshot(countStore)
function increment() {
countStore.count++
}
return <button onClick={increment}>Count: {snap.count}</button>
}
src/components/Count/CountLabel.tsx
import { useSnapshot } from 'valtio'
import { countStore } from '@/store/count'
export function CountLabel() {
const snap = useSnapshot(countStore)
return <p>Count: {snap.count}</p>
}
src/components/Count/Count.tsx
import { CountIncrementButton } from './CountIncrementButton'
import { CountLabel } from './CountLabel'
export function Count() {
const snap = useSnapshot(countStore)
return (
<div>
<CountIncrementButton />
<CountLabel />
</div>
)
}
Ever since React introduced hooks in 16.8, there's no reason to use class components any more.
Reduces the usage of props.<prop>
and looks cleaner:
interface Props {
prop1: boolean;
prop2: number;
prop3: string;
}
export function Example({ prop1, prop2, prop3 }: Props) {
return ...;
}
If you're creating a new component from scratch, create a new directory in the src/components
directory with the following file structure described in Project File Structure.
Sometimes you may want to conditionally add styles to a component. For example,
in response to a prop. You can conditionally add styles using the clsx
library:
import clsx from 'clsx'
import styles from './Example.module.scss'
interface Props {
prop1: boolean
prop2: number
}
export function Example({ prop1, prop2 }: Props) {
return (
<div
className={clsx(
'bg-red-500',
prop1 && 'border border-blue-500',
prop2 && styles.someClass
)}
>
...
</div>
)
}
Context is a React feature that allows us to pass data down an entire component
tree. Technically, that means we could implement global state using context +
useState()
, but it's not recommended because context forces the entire
component tree to rerender on state change, meaning state changes for a
particular component may cause unnecessary rerenders for unrelated components.
State management libraries like valtio
help with reducing these wasted
rerenders.
However, context is still beneficial in some contexts 🤣. Prop drilling is a pattern of passing down props deep in the component tree. Let's say we have calendar component that's implemented using 4 different components, and they all need access to the same date
prop. Prop drilling would look like:
src/components/Calendar/Calendar.tsx
import { CalendarMonthView } from './CalendarMonthView'
import { CalendarWeekView } from './CalendarWeekView'
import { CalendarToolbar } from './CalendarToolbar'
interface Props {
date: Date
type?: 'month' | 'week'
}
export function Calendar({ date, type = 'month' }: Props) {
return (
<div>
<CalendarToolbar date={date} />
{type === 'month' && <CalendarMonthView date={date} />}
{type === 'week' && <CalendarWeekView date={date} />}
</div>
)
}
src/components/Calendar/CalendarMonthView.tsx
interface Props {
date: Date
}
export function CalendarMonthView({ date }: Props) {
// render calendar month view
}
src/components/Calendar/CalendarToolbar.tsx
interface Props {
date: Date
}
export function CalendarToolbar({ date }: Props) {
// render calendar toolbar
}
src/components/Calendar/CalendarWeekView.tsx
interface Props {
date: Date
}
export function CalendarWeekView({ date }: Props) {
// render calendar week view
}
Do you see the pattern here? Every component is declaring the same prop date
each time, requiring us to pass the prop to every component that needs it. This is not very scalable because it means whenever we want to add new data, we'll need to add a new prop type and pass it to the component.
While the example above is simple, the overhead and complexity increases greatly when you add more components, more shared data, and when some of them only need a subset of the shared data.
To reduce prop drilling, we can use React's Context API to share data:
src/components/Calendar/context.tsx
import { ReactNode, createContext, useContext } from 'react'
interface CalendarData {
date: Date
}
const CalendarContext = createContext<CalendarData>({ date: new Date() })
interface Props extends CalendarData {
children: ReactNode
}
export function CalendarProvider({ children, date }): CalendarData {
return <CalendarContext value={{ date }}>{children}</CalendarContext>
}
export function useCalendar() {
const data = useContext(CalendarContext)
if (!data) {
throw new Error('useCalendar must be used in CalendarProvider')
}
return data
}
src/components/Calendar/Calendar.tsx
import { CalendarProvider } from './context'
import { CalendarMonthView } from './CalendarMonthView'
import { CalendarWeekView } from './CalendarWeekView'
import { CalendarToolbar } from './CalendarToolbar'
interface Props {
date: Date
type?: 'month' | 'week'
}
export function Calendar({ date, type = 'month' }: Props) {
return (
<CalendarProvider date={date}>
<div>
<CalendarToolbar />
{type === 'month' && <CalendarMonthView />}
{type === 'week' && <CalendarWeekView />}
</div>
</CalendarProvider>
)
}
src/components/Calendar/CalendarMonthView.tsx
import { useCalendar } from './context'
export function CalendarMonthView() {
const { date } = useCalendar()
// render calendar month view
}
src/components/Calendar/CalendarToolbar.tsx
import { useCalendar } from './context'
export function CalendarToolbar() {
const { date } = useCalendar()
// render calendar toolbar
}
src/components/Calendar/CalendarWeekView.tsx
import { useCalendar } from './context'
export function CalendarWeekView() {
const { date } = useCalendar()
// render calendar week view
}
Server Side Rendering (SSR) is a form of pre-rendering that works by fetching data on the web server and rendering it as part of the HTML response. Think of it like rendering HTML with PHP, but in JavaScript.
For most web applications, the frontend will request data from a web server dynamically at runtime and update the HTML with the response data. The benefit of using SSR is that any data that needs to be rendered on the page will be included in the HTML response from the server. For mobile users on slow internet speeds, the web server can fetch and process API data on a faster connection and send the rendered response to the user. For search engine indexers, it's faster to parse static HTML and improve SEO performance.
However, the major drawback of SSR is that data fetching and processing become a bottleneck at the rendering stage, meaning Time To First Byte (TTFB) may be reduced significantly if not careful. Because of this, it's important to only use SSR for critical data.
The line between what is critical and lazy is not always clear, but can be determined in a systematic way:
- Is the data content that needs to show up in a search engine?
- Does the HTML need to be available on initial render for some service?
The plugin description and metadata is a good example. People may want to search for a plugin by certain keywords that are present in the description or metadata. If we loaded this data using AJAX, then the search engine indexers would have a harder time loading the data because it's not available in the initial HTML. And while it's possible for Google to render and index JavaScript, it's a very slow process because it gets pushed to a queue with no indication of when it'll finish.
We also use SSR for social media tags so that we can generate page previews dynamically on a per-plugin level. Most social media websites will not parse JavaScript when looking for these tags, so it's critical that we add this data at the rendering phase.
In most cases though, the data you are fetching can probably be fetched dynamically with AJAX.
Data can be pre-rendered in a page using the getServerSideProps()
function:
export default function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
We use the react-query package for fetching data on the client:
import { useQuery } from 'react-query'
function fetchData() {
const response = await fetch('https://api.io')
return response.json()
}
function Example() {
const { data, isLoading, isError, error } = useQuery('api', fetchData)
if (isLoading) {
return <p>Loading...</p>
}
if (isError) {
return <p>Error fetching data: {error}</p>
}
return <pre>{JSON.stringify(data, null, 2)}</pre>
}
Internationalization is a framework for creating applications that can adapt to
different locales. Currently, we use i18next for
i18n on the frontend. i18n files are stored in the i18n
directory and include both json
and mdx
files.
The json
files are for storing copy strings that are used throughout the hub,
while mdx
files are used for rendering static, markdown pages.
Each i18n json
file represents an i18next
namespace. This allows us to
load only the required i18n files on a per-page level, reducing the amount of
data we send per page.
The useTranslation
hook can be used to access translation strings:
i18n/en/namespace.json
{
"count": "The count is {{count}}"
}
src/components/Count/Count.tsx
import { useTranslation } from 'next-i18next'
import { useState } from 'react'
function Count() {
const { t } = useTranslation(['namespace'])
const [count, setCount] = useState(0)
function increment() {
setCount(prev => prev + 1)
}
return (
<div>
<p>
{t('namespace:count', {
replace: { count },
})}
</p>
<button onClick={increment}>Increment</button>
</div>
)
}
The useTranslation
hook is preferred when you only need access to the strings. However, if you need to render a React node within the translation, you can use the I18n
component.
i18n/en/namespace.json
{
"count": "The count is {{count}}, <learnMoreLink>Learn More!</learnMoreLink>",
"learnMoreLink": "http://napari-hub.org/about"
}
src/components/Count/Count.tsx
import Link from 'next/link'
import { useTranslation } from 'next-i18next'
import { useState } from 'react'
import { I18n } from '@/components/components/I18n'
function Count() {
const { t } = useTranslation(['namespace'])
const [count, setCount] = useState(0)
function increment() {
setCount(prev => prev + 1)
}
return (
<div>
<p>
<I18n
values={{ count }}
i18nKey="namespace:count"
components={{
learnMoreLink: (
<Link
className="underline"
href={t('namespace:learnMoreLink')}
newTab
/>
),
}}
/>
</p>
<button onClick={increment}>Increment</button>
</div>
)
}
MDX pages follow the same rules for routing as pages, so creating a new mdx
file will also create a new page with the same file name.
For mdx
files, you can simply create a new file.
For json
files, you will also need to declare the file in several places in order for the frontend to detect it.
-
Create a new file for each language:
touch i18n/en/namespace.json touch i18n/es/namespace.json # For Spanish touch i18n/jp/namespace.json # For Japanese
-
Add the strings to the
I18nResources
object:diff --git a/frontend/src/constants/i18n.ts b/frontend/src/constants/i18n.ts index bad5750..92a1755 100644 --- a/frontend/src/constants/i18n.ts +++ b/frontend/src/constants/i18n.ts @@ -1,6 +1,7 @@ import common from '@/i18n/en/common.json'; import footer from '@/i18n/en/footer.json'; import homePage from '@/i18n/en/homePage.json'; +import namespace from '@/i18n/en/namespace.json'; import pageTitles from '@/i18n/en/pageTitles.json'; import pluginData from '@/i18n/en/pluginData.json'; import pluginPage from '@/i18n/en/pluginPage.json'; @@ -14,6 +15,7 @@ export const I18nResources = { common, footer, homePage, + namespace, pageTitles, pluginData, pluginPage,
-
Add namespace in
serverSideTranslations
:diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 9904eda..7d3cae6 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -29,6 +29,7 @@ export async function getServerSideProps({ 'homePage', 'pageTitles', 'pluginData', + 'namespace', ] as I18nNamespace[]); const props: Props = { ...translationProps };
When creating new files
Right now, we only have i18n for English. To add a new language, you can copy
the the i18n/en
directory and rename it to the the language code. For example,
if you want to create a Spanish locale:
cp -R i18n/en i18n/es
We use Jest for instrumenting all of our tests in the
frontend. Every test file ends with the .test.ts
extension, but the kind of
test it is depends on where the test is located.
Unit tests are used to test functions or components in isolation with their dependencies mocked. Integration tests are a step up where only some of the dependencies may be mocked, meaning we'd be testing the integration of multiple components or functions as part of a whole.
Right now, we don't have a clear separation of unit / integration tests, so they're treated similarly and up to the developer to decide what to mock.
Unit / Integration tests are located next to the module they're testing. They're
usually named the same, with the exception of having .test.ts
or .test.tsx
as the extension:
src/components/common/Markdown/
├── Markdown.test.tsx
├── Markdown.tsx
├── Markdown.utils.test.ts
├── Markdown.utils.ts
To run all tests:
yarn test
To run a specific test, you can specify a pattern for the file name:
# Will test Markdown.test.ts and Markdown.utils.test.ts
yarn test Markdown
If you're modifying a specific test, it's better to run it in watch mode so that Jest can re-run the test faster:
yarn test:watch Markdown
Snapshot tests are UI tests that write an HTML string representation of the component to a file, and then uses that file to compare against the latest snapshot to see if anything changed unexpectedly.
Since snapshot testing is part of the unit / integration tests, running yarn test
should be enough to also run the snapshot tests.
When you change a component, you may need to update the snapshots. You can do that by running:
yarn test:update
# For specific component test by pattern
yarn test:update Markdown
End-To-End (E2E) tests are used for testing the actual frontend of the napari hub. This works by automating a headless browser to test various workflows and features.
E2E tests are located in the tests/
directory, and they all end with the
extension .test.ts
.
To run the E2E tests:
yarn e2e
Similar to unit / integration tests, you can run a specific test by using a file pattern:
# Plugin page E2E tests
yarn e2e plugin
There is also a watch command you can use to re-run tests when updating the test code:
yarn e2e:watch plugin
When running the E2E tests, we test for multiple screens since the UI is
different depending on the screen size. To run a test at a specific screen size,
you can use the SCREEN
environment variable:
SCREEN=300 yarn e2e:watch plugin
If you want to be able to see the browser when running E2E tests, you can use the PWDEBUG
variable:
PWDEBUG=1 SCREEN=300 yarn e2e:watch plugin