React based user interface for the DINA project.
|
Important
|
Development Setup Required
Run and develop DINA locally using dina-local-deployment. ❌ Do NOT run directly through this UI repository. |
1. Getting Started
Before starting, you will need to setup your dev environment:
1.1. Node.js and Yarn
See dina-local-deployment prerequisites
You can check which versions you currently have installed with:
node -v yarn -v
1.2. Installing dependencies
After you have downloaded all of the required tools, you will need to install the required dependencies needed to run the app.
It’s a good idea to run this command when checking out a new branch since the dependencies might have changed.
yarn
2. Technology Stack
This application is developed using these technologies:
3. UI Design Guidelines
3.1. Page Layout
All page components should be aligned with the navigation and footer elements. The PageLayout component should be used as the root element to enforce this structure:
<PageLayout titleId="splitSubsampleTitle" buttonBarContent={buttonBar}>
... Main content of the page
</PageLayout>
3.2. Order of Components
The components on a page should follow this order:
-
Navigation Bar
-
Header
-
Button Bar
-
Main Content
-
Footer
3.3. Button Bar
The Back button should always be positioned on the left side and displayed as a link since it serves as a navigation element.
Action buttons like Edit, Create, Delete and Revisions should be placed on the right side.
3.4. Header
-
This is the main header text, depending on the page it will display:
-
List page: The plural name of the entity (eg. Assemblages)
-
View/Edit page: The name/identifier of the entity itself being edited. UUIDs should only be used if a name/identifier is not set.
-
-
Tooltips can be added to the right to describe the entity itself. Should be referenced from the DINA user docs.
-
Group name will be displayed on the right only for viewing and editing records. It should be all capital letters.
4. Running Tests
4.1. Run all tests
Run yarn test from the top-level dina-ui repo.
4.2. Run individual tests
Either
-
From VS Code: Install the Jest Runner extension, which adds Run/Debug buttons to your test files.
-
From the command line:
-
Using just the file name:
yarn jest QueryTable.test.tsx -
Using the full path (when there is more than one file with this name):
yarn jest packages/common-ui/lib/table/tests/QueryTable.test.tsx
-
5. Versioning
Dina UI’s version is stored in the dina-ui package’s package.json (Not the repo’s top-level package.json). You can update this version when releasing a new version of the application by running this command at the repo’s top level:
yarn --cwd ./packages/dina-ui version
6. Building the app Docker images
From the top-level directory, run the following commands to build the dina-ui Docker image:
# Install dependencies
yarn
# Build the nextJS static pages and application.
yarn workspace dina-ui build
# Build the Docker image
docker build -t dina-ui:dev .
6.1. Environment variables
6.1.1. Required Addresses for Back-end APIs
| Variable | Description | Example |
|---|---|---|
|
Object storage back-end API endpoint |
|
|
User management back-end API endpoint |
|
|
Collection management back-end API endpoint |
|
|
Agent management back-end API endpoint |
|
|
Sequence database back-end API endpoint |
|
|
Search service endpoint |
|
|
Loan transaction back-end API endpoint |
|
|
Report and label export API endpoint |
|
6.1.2. Authentication Variables
Configure these variables to enable Keycloak authentication:
| Variable | Description | Example |
|---|---|---|
|
Enable/disable Keycloak authentication |
|
|
Keycloak client identifier |
|
|
Keycloak realm name |
|
|
External-facing Keycloak URL |
6.1.3. Instance Configuration
Customize your DINA instance with these variables:
| Variable | Description | Example |
|---|---|---|
|
Deployment mode for the instance |
|
|
Display name shown in the UI header |
|
|
Comma-separated language codes |
|
|
Skip IE browser blocking check |
|
6.1.4. External Services (Optional)
Configure external data sources and services:
| Variable | Description | Example |
|---|---|---|
|
Comma-separated coordinate systems |
|
|
Scientific name lookup service URL |
|
|
Scientific name datasets service URL |
7. React Components
7.1. UI Common React Component Types
7.1.1. Pages
Stored in "packages/dina-ui/pages". The folder path and filename correspond to the page’s URL, which is automatically routed by Next.js.
Pages typically use one of the standard page layouts (ViewPageLayout, ListPageLayout, etc.) and integrate with our data fetching patterns using React Query hooks.
7.1.2. Page Layouts
Standard layouts located in "packages/dina-ui/components":
-
PageLayout - Base layout with header, footer, and main content area.
-
ViewPageLayout - For viewing/displaying a single resource with view/edit modes
-
ListPageLayout - For displaying API base lists of resources with filtering and pagination
-
QueryPage - For display elasticsearch based list pages.
-
EditPageLayout - For editing existing resources
These layouts provide consistent structure and behavior across pages, including:
-
Navigation breadcrumbs
-
Action buttons (Save, Cancel, Delete, etc.)
-
Error handling and loading states
-
Consistent styling and spacing
7.1.3. DinaForm
The main way to create forms in dina-ui, located in "packages/common-ui/lib/formik-connected/DinaForm.tsx".
It’s a wrapper around Formik that provides:
-
Integration with our API layer for saving resources
-
Automatic error message handling and display
-
Support for relationships and linked resources
-
Revision management and conflict detection
-
Bulk editing capabilities
Key props:
-
initialValues- The initial form data -
onSubmit- Custom submit handler -
readOnly- Makes all fields read-only -
enableReinitialize- Allows form to update when initialValues change
Example form that on submit either shows a success alert or an error message:
import { DinaForm, SubmitButton, TextField } from "../components";
export default function ExampleFormPage() {
const onSubmit: DinaFormOnSubmit = async ({
submittedValues,
api: { save }
}) => {
if (!submittedValues.name) {
throw new Error("Name must be set.");
}
// Makes a Create or Update call to the back-end.
await save(
[
{
resource: submittedValues,
type: "my-type"
}
],
{ apiBaseUrl: "/objectstore-api" }
);
};
return (
<div className="card card-body" style={{ width: "1000px" }}>
<DinaForm initialValues={{}} onSubmit={onSubmit}>
<TextField name="name" />
<TextField name="description" />
<SubmitButton />
</DinaForm>
</div>
);
}
7.1.4. Form Fields
Form input components that connect to DinaForm via the name prop. Located in "packages/common-ui/lib/formik-connected/".
Common field types:
-
TextField - Text input
-
TextArea - Multi-line text input
-
DateField - Date picker
-
NumberField - Numeric input with optional range
-
SelectField - Dropdown selection
-
CheckBoxField - Boolean checkbox
-
ResourceSelectField - Autocomplete selection from API resources
-
AutoSuggestTextField - Text field with dropdown suggestions
-
FieldSet - Groups related fields
-
FieldArray - Manages arrays of values with add/remove functionality
-
FilterBuilderField - Component for creating complex filter queries
All fields support:
-
name- Property path in form data (supports nested paths like "group.name") -
label- Display label (auto-generated from name if not provided) -
readOnly- Makes field read-only -
customName- Custom label for display -
className- CSS classes (typically Bootstrap grid classes) -
removeBottomMargin- Removes bottom margin for compact layouts
Example:
<DinaForm initialValues={{ person: { name: "", age: 0 } }}>
<TextField name="person.name" className="col-md-6" />
<NumberField name="person.age" className="col-md-6" />
<SubmitButton />
</DinaForm>
Getting a Field value from the form
Sometimes you will want to get a field’s value in one component without re-rendering the surrounding component. You can listen to a Field’s state using FieldSpy:
import { DinaForm, SubmitButton, TextField, FieldSpy } from "common-ui";
export default function ExampleFormPage() {
return (
<div className="card card-body" style={{ width: "1000px" }}>
<DinaForm initialValues={{}} onSubmit={onSubmit}>
<TextField name="name" />
<FieldSpy fieldName="name">
{value => `Your name is ${value}`}
</FieldSpy>
<SubmitButton />
</DinaForm>
</div>
);
}
Tooltips
The tooltip is found from the intl messages file using the key "field_{fieldName}_tooltip". As an example:
<TextField name="name" />
<TextField name="description" />
// In the dina-ui-en.ts file:
field_name_tooltip: "This message will appear in the name fields tooltip."
field_description_tooltip: "This message will appear in the description fields tooltip."
Tooltip ids can also be manually added using the customName property. This can be handy for fields that share a common name like "description". Here is an example of using a custom tooltip id:
<TextField name="name" customName="metadataName" />
// In the dina-ui-en.ts file:
field_metadataName_tooltip: "This will be the message that appears for the name fields tooltip."
You are also able to add images and links using the following properties on any of the Field components. Below is a table of all of the different supported properties for images and links.
| Property Name | Description | Example |
|---|---|---|
tooltipImage |
URL of a image to display in the tooltip. It is displayed under the tooltip texts (if supplied). |
tooltipImage="/images/tooltip_image.png" |
tooltipImageAlt |
Accessibility text which should be used when a tooltipImage is provided. Best practice is to use internationalization key so it works in multiple languages. |
tooltipImageAlt="tooltip_image_alt" |
tooltipLink |
URL of a link to display at the bottom of the tooltip. |
tooltipLink="https://www.google.com" |
tooltipLinkText |
Text identifier to appear in the tooltip link text. Defaults to a generic message if not supplied. |
tooltipLinkText="tooltip_link_reference" |
ResourceSelectField Component
This is a searchable dropdown select component for selecting a back-end resource from a list.
Example usage for selecting a Group from a list:
import { DinaForm, ResourceSelectField } from "common-ui";
import { Group } from "../types/user-api/resources/Group";
/**
* This page shows a dropdown for selecting a Group. You can type the name of a Group you're
* looking for into the input, which sends a filtered query to the back-end API for a Group with
* that name. When you select a Group, the onChange callback function sets the selected group into
* the page's component state. The page displays a message with the name of the currently selected
* Group.
*/
export default function ExamplePage() {
return (
<div className="card card-body" style={{ width: "500px" }}>
<DinaForm initialValues={{ myGroup: null }}>
{({ values: { myGroup } }) => (
<div>
<h2>Selected group: {myGroup ? myGroup.groupName : "None"}</h2>
<ResourceSelectField<Group>
name="myGroup"
filter={groupName => ({ groupName })}
model="group"
optionLabel={group => group.groupName}
/>
</div>
)}
</DinaForm>
</div>
);
}
AutoSuggestTextField Component
Using the AutoSuggestTextField component, you can provide a dropdown of suggestions for a text field as the user types.
The AutoSuggestTextField component supports JSON API and elastic search for auto-suggestions, you can even provide both and fallback to the JSON API if the elastic search fails for example.
Important: This is used for retrieving text suggestions. The form will be returned a string. Not a UUID for a resource. Checkout the ResourceSelectField for that functionality.
JSON API example
To configure the JSON API auto-suggestions, you need to setup the jsonApiBackend prop:
<AutoSuggestTextField<Person>
name="examplePersonNameField"
jsonApiBackend={{
query: searchValue => ({
path: "agent-api/person",
fiql: simpleSearchFilterToFiql(
SimpleSearchFilterBuilder.create<Organism>()
.searchFilter("name", searchValue)
.build()
)
}),
option: person => person?.name
}}
/>
The query defines the path and any filters you wish to use. You can also easily retrieve the current search value to filter by.
The option is used to determine the text to display for each suggestion. This is also the value that is returned to the form.
Elastic Search example
To configure the Elastic Search auto-suggestions, you need to setup the elasticSearchBackend prop:
<AutoSuggestTextField<Person>
name="examplePersonNameField"
elasticSearchBackend={{
indexName: "dina_agent_index",
searchField: "data.attributes.name",
option: person => person?.name
}}
/>
-
indexNameis the name of the Elastic Search index to use. (required) -
searchFieldis the name of the field to search in, this needs to be the full path. (required) -
additionalFieldis another field to search by in addition to the search field. This also needs to the full path. (optional) -
restrictedFieldis used to filter by a specific field in the index. This also needs to the full path. (optional) -
restrictedFieldValueis the value to search against the restricted field. (optional) -
optionis used to determine the text to display for each suggestion. This is also the value that is returned to the form. (required)
Typescript Support
The AutoSuggestTextField component supports Typescript. To use it, you need to import the AutoSuggestTextField component and pass it the type of the resource you are searching for.
For example for a Person (Kitsu Resource):
<AutoSuggestTextField<Person> />
Providing the type, will make the option section of the component more specific:
<AutoSuggestTextField<Person>
name="examplePersonNameField"
elasticSearchBackend={{
indexName: "dina_agent_index",
searchField: "data.attributes.name",
option: person => person?.name // This part you can now say .name since the type was defined.
}}
/>
If you are using an included search field, ensure the type being used contains the field you are looking for. If it doesn’t exist on the type it will not be parsed.
Default to elastic search, fall back to JSON API example
<AutoSuggestTextField<Person>
name="examplePersonNameField"
elasticSearchBackend={{
indexName: "dina_agent_index",
searchField: "data.attributes.name",
option: person => person?.name
}}
jsonApiBackend={{
query: searchValue => ({
path: "agent-api/person",
fiql: simpleSearchFilterToFiql(
SimpleSearchFilterBuilder.create<Organism>()
.searchFilter("name", searchValue)
.build()
)
}),
option: person => person?.name
}}
preferredBackend={"elastic-search"} // Default to elastic search
/>
In the example above, both "elastic-search" and "json-api" are supplied. Elastic search will be used first and it will keep using elastic search until any errors occur.
Once an error occurs it will switch to the other available backend, in this case the "json-api".
It’s important to make sure both providers work correctly since it will probably be rare that provider will fail.
Custom options example
You can provide your own suggestions directly using the customOptions prop:
<AutoSuggestTextField<Person>
name="examplePersonNameField"
customOptions={value => [
"suggestion-1",
"suggestion-2",
"suggestion-" + value
]}
/>
In this example the following suggestions will be provided if the user types "3" into the text field:
-
suggestion-1
-
suggestion-2
-
suggestion-3
While you can provide static options, you can also call an API to populate these results or another function:
<AutoSuggestTextField
name={fieldProps("matchValue", index)}
blankSearchBackend={"preferred"}
customOptions={value =>
useElasticSearchDistinctTerm({
fieldName:
dataFromIndexMapping?.parentPath +
"." +
dataFromIndexMapping?.path +
"." +
dataFromIndexMapping?.label,
groups: selectedGroups,
relationshipType: dataFromIndexMapping?.parentName,
indexName
})?.filter(suggestion =>
suggestion?.toLowerCase()?.includes(value?.toLowerCase())
)
}
/>
Blank Search provider example
You can even provide suggestions even if the text field is blank. Using the blankSearchProvider property, you can configure what happens during a blank search.
If you would like blank searches to occur with the current provider you can use the "preferred" option:
<AutoSuggestTextField<Person>
name="examplePersonNameField"
elasticSearchBackend={{
indexName: "dina_agent_index",
searchField: "data.attributes.name",
option: person => person?.name
}}
jsonApiBackend={{
query: searchValue => ({
path: "agent-api/person",
fiql: simpleSearchFilterToFiql(
SimpleSearchFilterBuilder.create<Organism>()
.searchFilter("name", searchValue)
.build()
)
}),
option: person => person?.name
}}
preferredBackend={"elastic-search"}
blankSearchBackend={"preferred"}
/>
In this example elastic search is the default provider. On a blank search elastic search is used unless it fails then the JSON API is used.
Preferred is useful if you don’t care which backend provider is used for a blank search.
Otherwise you can specify a specific backend to use for blank searches, even if it differs from the currently selected one.
<AutoSuggestTextField<Person>
name="examplePersonNameField"
elasticSearchBackend={{
indexName: "dina_agent_index",
searchField: "data.attributes.name",
option: person => person?.name
}}
jsonApiBackend={{
query: searchValue => ({
path: "agent-api/person",
fiql: simpleSearchFilterToFiql(
SimpleSearchFilterBuilder.create<Organism>()
.searchFilter("name", searchValue)
.build()
)
}),
option: person => person?.name
}}
preferredBackend={"elastic-search"}
blankSearchBackend={"json-api"}
/>
In the example above, elastic search is the default provider. On a blank search JSON API is used only if the search is blank.
If elastic search fails, then JSON API will be used for both blank and non-blank searches.
If you wish to not display any suggestions on a blank search, you can just remove the blankSearchBackend property and no suggestions will appear until a value is provided.
FilterBuilderField component
The FilterBuilderField component provides a Formik input for creating complex filter queries. The FilterGroupModel value this input provides can be converted to an RSQL string to be included in the API request.
Example usage for filtering PcrPrimers in a list page:
import { FilterParam } from "kitsu";
import { useState } from "react";
import { FilterBuilderField } from "../components/filter-builder/FilterBuilderField";
import { rsql } from "../components/filter-builder/rsql";
import Head from "../components/head";
import Nav from "../components/nav";
import { QueryTable } from "../components/table/QueryTable";
import { DinaForm, DinaFormOnSubmit } from "../components/formik-connected/DinaForm";
export default function ExamplePage() {
const [filter, setFilter] = useState<FilterParam>();
const onSubmit: DinaFormOnSubmit = ({ submittedValues: { filter } }) => {
setFilter({ rsql: rsql(filter) });
};
return (
<div>
<Head />
<Nav />
<div className="container-fluid">
<DinaForm initialValues={{}} onSubmit={onSubmit}>
<FilterBuilderField
filterAttributes={["name", "group.groupName"]}
name="filter"
/>
<button type="submit">search</button>
</DinaForm>
<QueryTable
columns={["name", "type", "group.groupName"]}
filter={filter}
include="group"
path="pcrPrimer"
/>
</div>
</div>
);
}
7.1.5. Custom Hooks
Reusable hooks for common patterns:
-
useQuery - Data fetching
-
useDinaFormContext - Access DinaForm context
-
useApiClient - Access API client
-
useBulkEditTab - Manage bulk editing
-
useModal - Modal dialog management
7.2. Audit / Revisions UI
The "revisions" pages show edit history of specific records and user activity.
Audit records are fetched from the back-end which is implemented using Javers. See the DINA back-and auditing docs.
7.2.1. Adding audit support for one Resource Type
For tracking changes to a single record. This page should be linked to from the record’s details page.
You can re-use the RevisionsPage component to create a revisions page for the specified resource:
Example from dina-ui/pages/collection/material-sample/revisions.tsx:
import { COLLECTION_MODULE_REVISION_ROW_CONFIG } from "../../../components/revisions/revision-modules";
import { RevisionsPage } from "../../../components/revisions/RevisionsPageLayout";
export default () => (
<RevisionsPage
// The API route to the Javers snapshot list
auditSnapshotPath="collection-api/audit-snapshot"
// URL to the details page
detailsPageLink="/collection/material-sample/view?id="
// Query for the current state of the record
queryPath="collection-api/material-sample"
// The JSONAPI resource "type"
resourceType="material-sample"
// The row configs are specific to each module (Collection, Object Store, Agent, etc.)
revisionRowConfigsByType={COLLECTION_MODULE_REVISION_ROW_CONFIG}
// The field on the resource that gives the name. Usuallu this field is called "name"
nameField="materialSampleName"
/>
);
7.2.2. Adding audit support for a user’s edits across a DINA module
For tracking all changes to any record by a user. This page should be linked to from the main UI navigation.
There is a separate "revisions-by-user" page per DINA module (Collection, Object Store, Agent, etc.) because the back-end is a separate application for each DINA module. There is no way to retrieve a user’s activity across all multiple back-ends in one query.
You can re-use the RevisionsByUserPage component to create a revisions page for the specified DINA module:
Example from dina-ui/pages/collection/revisions-by-user.tsx:
import { COLLECTION_MODULE_REVISION_ROW_CONFIG } from "../../components/revisions/revision-modules";
import RevisionsByUserPage from "../../components/revision-by-user/CommonRevisionsByUserPage";
export default function CollectionRevisionByUserPage() {
return (
<RevisionsByUserPage
// The API route to the Javers snapshot list
snapshotPath="collection-api/audit-snapshot"
// The row configs are specific to each module (Collection, Object Store, Agent, etc.)
revisionRowConfigsByType={COLLECTION_MODULE_REVISION_ROW_CONFIG}
/>
);
}
7.2.3. Configuring how audit data is displayed in the "Show Changes" section.
The back-end provides historical data as unstructured JSON (in the "state" field), so the UI might not always be able to show a good looking UI based on the data it’s given because the data schema is unknown. Simple string or number fields are easily rendered as-is, but the last-resort fallback for an unknown object structure is to show stringified JSON in the UI. The Revision UI components are written in a way that unstructured data is displayed generically by looping through the key-value pairs of the given JSON, but specific field names can be manually overwritten in the code to improve the way that the UI displays those fields.
All edited fields will be rendered generically in the Revisions UI, but to manually register fields to be rendered in a specific way, go to:
dina-ui/components/revisions/revision-modules.ts
and add your typename into the map for the correct DINA module.
For examples of types added:
import { RevisionRowConfigsByType } from "./revision-row-config";
import { COLLECTING_EVENT_REVISION_ROW_CONFIG } from "./revision-row-configs/collectingevent-revision-row-config";
import { MATERIAL_SAMPLE_REVISION_ROW_CONFIG } from "./revision-row-configs/material-sample-revision-row-configs";
import { METADATA_REVISION_ROW_CONFIG } from "./revision-row-configs/metadata-revision-row-config";
/** Custom revision row behavior for Object Store Resources. */
export const OBJECT_STORE_MODULE_REVISION_ROW_CONFIG: RevisionRowConfigsByType = {
metadata: METADATA_REVISION_ROW_CONFIG
};
/** Custom revision row behavior for Object Store Resources. */
export const COLLECTION_MODULE_REVISION_ROW_CONFIG: RevisionRowConfigsByType = {
"collecting-event": COLLECTING_EVENT_REVISION_ROW_CONFIG,
"material-sample": MATERIAL_SAMPLE_REVISION_ROW_CONFIG
};
-
In the Object Store module, mappings have been added for the "metadata" type.
-
In the Collection module, mappings have been added for the "collecting-event" and "material-sample" types.
See the implementation of MATERIAL_SAMPLE_REVISION_ROW_CONFIG for examples of field renderers.
8. API Client
8.1. useQuery Hook Function
useQuery is an API querying function using the React Hooks API. You can use it to easily query the back-end from inside a function component.
Example usage for fetching a single resource:
import { useQuery } from "../components";
import { PcrPrimer } from "../types/seqdb-api/resources/PcrPrimer";
function ExampleComponent() {
const { error, loading, response } = useQuery<PcrPrimer>({ path: "pcrPrimer/1" });
if (loading) {
return <div>Loading...</div>;
} else if (error) {
return <ErrorInfo error={error} />;
} else {
return <div>Primer name: {response.data.name}</div>;
}
}
Example usage for fetching a resource list:
import { useQuery } from "../components";
import { PcrPrimer } from "../types/seqdb-api/resources/PcrPrimer";
function ExampleComponent() {
const { error, loading, response } = useQuery<PcrPrimer[]>({ path: "pcrPrimer" });
if (loading) {
return <div>Loading...</div>;
} else if (error) {
return <ErrorInfo error={error} />;
} else {
return (
<div>
{response.data.map(primer => (
<PcrPrimerInfo key={primer.id} primer={primer} />
))}
</div>
);
}
}
Using the "withResponse" helper function to render generic loading and error components with minimal code, instead of writing if/else blocks for each state:
import { useQuery, withResponse } from "../components";
import { PcrPrimer } from "../types/seqdb-api/resources/PcrPrimer";
function ExampleComponent() {
const primerQuery = useQuery<PcrPrimer[]>({ path: "pcrPrimer" });
return withResponse(primerQuery, ({ data: primers }) => (
<div>
{primers.map(primer => (
<PcrPrimerInfo key={primer.id} primer={primer} />
))}
</div>
));
}
8.2. Performing write operations
This application uses React’s hooks and context API to provide a "save" function which allows you to perform write operations against the back-end API. "save" accepts an array of operations to perform, so you can submit multiple resource operations in a single transaction in a single HTTP request.
If one operation fails, the entire transaction is cancelled, and "save" throws an Error, which provides an aggregation message of all of the error messages. This function relies on the back-end to support JSONAPI’s jsonpatch extension (implemented by crnk-operations).
Here is a simple example of a component that uses the "save" function from the context to provide a button that creates a new Region with a random name and symbol:
import React from "react";
import { useApiClient } from "common-ui";
export funcion NewRegionButton() {
const { save } = useApiClient();
async function createRegion() {
await save(
[
{
resource: {
name: `new-region-${Math.random()}`,
seq: "",
symbol: `${Math.random()}`,
type: "PRIMER"
},
type: "region"
}
],
{ apiBaseUrl: "/seqdb-api" }
);
};
return <button onClick={this.createRegion}>Create Region</button>;
}
9. Creating Tests
10. Creating Tests for DINA UI
10.1. Test File Structure
import { mountWithAppContext } from "common-ui";
import { fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
describe("ComponentUnderTest", () => {
const mockGet = jest.fn();
const mockSave = jest.fn();
const apiContext = {
apiClient: {
get: mockGet
},
save: mockSave
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should render and fetch data", async () => {
mockGet.mockResolvedValueOnce({
data: [{ id: "1", type: "person", name: "John" }]
});
const wrapper = mountWithAppContext(
<ComponentUnderTest />,
{ apiContext }
);
// Wait for the component to load and display data
await waitFor(() => {
expect(wrapper.getByText("John")).toBeInTheDocument();
});
expect(mockGet).toHaveBeenCalledWith("person", { page: { limit: 10 } });
});
});
10.2. Debugging Tests
10.2.1. Visual Debugging with Testing Playground
When a test fails or you need to see what the component looks like, use screen.logTestingPlaygroundURL() to open an interactive playground in your browser:
import { screen } from "@testing-library/react";
it("should display form elements", async () => {
const wrapper = mountWithAppContext(<PersonForm />, { apiContext });
await waitFor(() => {
expect(wrapper.getByRole("textbox", { name: /name/i })).toBeInTheDocument();
});
// Open Testing Playground to visually inspect the DOM
screen.logTestingPlaygroundURL();
// The test will output a URL like:
// https://testing-playground.com/#markup=...
// Open this URL in your browser to see the rendered component
// and get suggestions for better queries
});
|
Tip
|
The Testing Playground URL shows you the component’s HTML structure and helps you find the best queries to use. It’s especially useful when you’re not sure which role or text to query by. |
10.3. Mounting Components
import { mountWithAppContext } from "common-ui";
// Basic mount
const wrapper = mountWithAppContext(<MyComponent />);
// With API context
const wrapper = mountWithAppContext(
<MyComponent />,
{ apiContext: { apiClient: { get: mockGet }, save: mockSave } }
);
10.4. Network Mocking Patterns
10.4.1. Mock GET Requests
const mockGet = jest.fn();
// Single resource
mockGet.mockResolvedValueOnce({
data: {
id: "123",
type: "person",
attributes: { name: "John" }
}
});
// List of resources
mockGet.mockResolvedValueOnce({
data: [
{ id: "1", type: "person", name: "John" },
{ id: "2", type: "person", name: "Jane" }
],
meta: { totalResourceCount: 2 }
});
// With includes
mockGet.mockResolvedValueOnce({
data: [
{
id: "1",
type: "material-sample",
relationships: {
collection: { data: { id: "col-1", type: "collection" } }
}
}
],
included: [
{ id: "col-1", type: "collection", name: "Main Collection" }
]
});
// Conditional mocking based on path
const mockGet = jest.fn(async (path) => {
switch (path) {
case "objectstore-api/metadata/123":
return { data: TEST_METADATA };
case "objectstore-api/license":
return { data: TEST_LICENSES };
case "agent-api/person":
return { data: [] };
}
});
10.4.2. Mock BulkGet Operations
const mockBulkGet = jest.fn<any, any>(async (paths: string[]) =>
paths.map((path) => {
switch (path) {
case "person/123":
return {
id: "123",
type: "person",
displayName: "John Doe"
};
}
})
);
const apiContext: any = {
apiClient: { get: mockGet },
bulkGet: mockBulkGet,
save: mockSave
};
10.4.3. Mock SAVE Operations
const mockSave = jest.fn();
// Create resource
mockSave.mockResolvedValueOnce([
{
id: "new-123",
type: "person",
name: "New Person"
}
]);
// Update resource
mockSave.mockResolvedValueOnce([
{
id: "123",
type: "person",
name: "Updated Person"
}
]);
// Bulk operations - return all saved resources
mockSave.mockImplementation(async (saves) => {
return saves.map(save => ({
...save.resource,
id: save.resource.id || "new-id"
}));
});
10.4.4. Mock Operations API
const mockPatch = jest.fn();
apiContext.apiClient.axios = { patch: mockPatch } as any;
mockPatch.mockResolvedValueOnce({
data: [
{
data: { id: "1", type: "person", attributes: { name: "John" } },
status: 201
}
]
});
10.4.5. Mock Errors
// GET error
mockGet.mockRejectedValueOnce({
response: {
status: 404,
statusText: "Not Found"
}
});
// SAVE error
mockSave.mockRejectedValueOnce({
response: {
status: 422,
data: {
errors: [{
detail: "Name is required",
source: { pointer: "/data/attributes/name" }
}]
}
}
});
10.5. Testing Component Behavior
10.5.1. Query Elements
import { fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
it("should display form elements", async () => {
const wrapper = mountWithAppContext(<PersonForm />, { apiContext });
// Wait for component to load
await waitFor(() => {
expect(wrapper.getByRole("textbox", { name: /name/i })).toBeInTheDocument();
});
// Query by role
const nameInput = wrapper.getByRole("textbox", { name: /name/i });
const submitButton = wrapper.getByRole("button", { name: /submit/i });
const checkbox = wrapper.getByRole("checkbox", { name: /active/i });
const combobox = wrapper.getByRole("combobox", { name: /category/i });
// Query by text
expect(wrapper.getByText(/welcome/i)).toBeInTheDocument();
// Query by display value
expect(wrapper.getByDisplayValue("John Doe")).toBeInTheDocument();
// Check if element has value
expect(nameInput).toHaveDisplayValue("John");
});
10.5.2. Test Form Submission
import { fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("should save form data", async () => {
mockSave.mockResolvedValueOnce([
{ id: "123", type: "person", name: "John" }
]);
const wrapper = mountWithAppContext(<PersonForm />, { apiContext });
// Wait for form to load
await waitFor(() => {
expect(wrapper.getByRole("textbox", { name: /name/i })).toBeInTheDocument();
});
// Fill form using fireEvent
fireEvent.change(wrapper.getByRole("textbox", { name: /name/i }), {
target: { value: "John" }
});
// Or use userEvent for more realistic interactions
userEvent.click(wrapper.getByRole("button", { name: /submit/i }));
// Submit form
fireEvent.submit(wrapper.container.querySelector("form")!);
// Wait for save to complete
await waitFor(() => {
expect(mockSave).toHaveBeenCalledWith(
[{
resource: { name: "John", type: "person" },
type: "person"
}],
{ apiBaseUrl: "/agent-api" }
);
});
});
10.5.3. Test User Interactions
import { fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("should handle user interactions", async () => {
const wrapper = mountWithAppContext(<MyComponent />, { apiContext });
await waitFor(() => {
expect(wrapper.getByRole("button", { name: /add tag/i })).toBeInTheDocument();
});
// Click button
userEvent.click(wrapper.getByRole("button", { name: /add tag/i }));
// Change input
fireEvent.change(wrapper.getByRole("combobox", { name: /tags/i }), {
target: { value: "new tag" }
});
// Select option
userEvent.click(wrapper.getByRole("option", { name: /add "new tag"/i }));
// Toggle switch
userEvent.click(wrapper.getByRole("switch"));
// Remove item
userEvent.click(wrapper.getByRole("button", { name: /remove tag1/i }));
// Wait for new element to appear
await waitFor(() => {
expect(wrapper.getByText(/new tag/i)).toBeInTheDocument();
});
});
10.5.4. Test Dropdown Selection
it("should update dropdown selection", async () => {
const wrapper = mountWithAppContext(<MyForm />, { apiContext });
await waitFor(() => {
expect(wrapper.getByRole("combobox", { name: /license/i })).toBeInTheDocument();
});
// Open dropdown
userEvent.click(wrapper.getByRole("combobox", { name: /license/i }));
// Select option
userEvent.click(wrapper.getByRole("option", { name: /<none>/i }));
// Verify selection
expect(wrapper.getByRole("combobox", { name: /license/i })).toHaveDisplayValue("");
});
10.6. Mocking Next.js Router
const mockUseRouter = jest.fn();
jest.mock("next/router", () => ({
useRouter: () => mockUseRouter()
}));
describe("My Component", () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseRouter.mockReturnValue({
push: jest.fn(),
query: {
id: "123"
}
});
});
it("should use router query params", async () => {
const wrapper = mountWithAppContext(<MyComponent />, { apiContext });
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining("123")
);
});
});
});
10.7. Testing Hooks
import { renderHook, waitFor } from "@testing-library/react";
it("should fetch data with hook", async () => {
mockGet.mockResolvedValueOnce({
data: [{ id: "1", type: "person" }]
});
const { result } = renderHook(
() => useApiClient(),
{
wrapper: ({ children }) => (
<ApiClientProvider value={apiContext}>
{children}
</ApiClientProvider>
)
}
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toHaveLength(1);
});
10.8. Best Practices
10.8.1. Always use waitFor for async operations
await waitFor(() => {
expect(wrapper.getByText("Loaded")).toBeInTheDocument();
});
10.8.2. Use appropriate queries
-
getByRole- preferred for accessibility -
getByText- for text content -
getByDisplayValue- for input values -
getByLabelText- for form fields with labels
10.8.3. Check only changed values in saves
expect(mockSave).lastCalledWith(
[{
resource: {
id: "123",
type: "metadata",
// Only include fields that were changed
name: "Updated Name"
},
type: "metadata"
}],
{ apiBaseUrl: "/objectstore-api" }
);
11. Internationalization
11.1. Internationalization / Multiple Languages Support
This application can display messages in multiple languages using react-intl.
11.1.1. Message components
When developing UI, you should not hard-code messages into the UI, like this:
<h2>PCR Primers</h2>
You should instead reference a message key:
import { DinaMessage } from "../../intl/dina-intl.ts""
...
<h2><DinaMessage id="pcrPrimerListTitle" /></h2>
11.1.2. formatMessage function
You can also use the "formatMessage" function if you want to reference a message outside of a JSX tree:
import { useDinaIntl } from "../../dina-intl.ts""
function MyComponent() {
const { formatMessage } = useDinaIntl();
useEffect(() => {
alert(formatMessage("greeting"));
}, []);
return (
<div>...</div>
);
}
11.1.3. Defining messages
The actual text for these messages needs to be defined in a messages file, like dina-en.ts. Each UI app ( dina-ui is the only UI app at the moment ) should have its own messages files. common-ui has its own messages files for shared messages used by multiple apps.
dina-en.ts:
export const DINA_MESSAGES_ENGLISH = {
...
greeting: "Hello!"
...
}
Type-checking is enabled so the message ID passed into "DinaMessage" or "formatMessage" MUST be a key in at least the English messages file. To add a French (or other language) translation for a message, the English version of the message must already be defined. The UI uses the English translation as a fallback if the message has no translation for the user’s locale.
Example from dina-fr.ts:
export const DINA_MESSAGES_FRENCH: Partial<typeof DINA_MESSAGES_ENGLISH> = {
...
greeting: "Bonjour!", // If the key exists in the english messages file, Typescript allows it.
otherKey: "C'est un autre message." // If "otherKey" isn't a key in the English messages, this should show an error in your IDE.
...
};
11.1.4. Field label messages
Some components, like QueryTable, FilterBuilder and FieldWrapper (which wraps our Formik input components)
take in a list of field names ( e.g. ["name","version","group.groupName"] ) and display auto-generated title-case
labels of these field names ( e.g. Name, Version, Group Group Name ). Sometimes the auto-generated labels need to
be manually overridden, so you can define custom messages for these labels using the field_${fieldName} key format.
Example:
export const DINA_MESSAGES_ENGLISH = {
...
field_name: "Name",
"field_group.groupName": "Group Name",
...
}
export const DINA_MESSAGES_FRENCH: Partial<typeof DINA_MESSAGES_ENGLISH> = {
...
field_name: "Nom",
"field_group.groupName": "Nom de Groupe"
...
}
Field tooltip messages
You can add an additional tooltip message to go inside the Field Label after the field name. After adding the field label message, add a tooltip message by adding "_tooltip" to the end of the field label’s message.
Example:
export const DINAUI_MESSAGES_ENGLISH = {
...
field_publiclyReleasable: "Publicly releasable",
field_publiclyReleasable_tooltip:
"Indicates if the object could be released publicly on a web page or open data portals.",
...
}
11.1.5. Export messages as CSV
Run the following command to export the messages in CSV format:
cd ./packages/scripts
yarn --silent export-intl-csv > messages.csv
11.1.6. Import CSV files to update Typescript message files
Run the following command after editing your CSV file:
cd ./packages/scripts CSVFILE=messages.csv yarn import-intl-csv
12. WCAG compliance tools setup
12.1. Pa11y
-
Install Pa11y
git clone https://github.com/pa11y/pa11y.git cd pa11y
-
Run Pa11y for multiple pages
copy below example multiple.js file to pa11y dir
// An example of running Pa11y on multiple URLS
'use strict';
const pa11y = require('.');
runExample();
// Async function required for us to use await
async function runExample() {
try {
// Put together some options to use in each test
const options = {
log: {
debug: console.log,
error: console.error,
info: console.log
}
};
// Run tests against multiple URLs
const results = await Promise.all([
pa11y('http://localhost:8000/product/list', options),
pa11y('http://localhost:8000/product/edit?id=1', options),
pa11y('http://localhost:8000/product/view?id=1', options)
]);
} catch (error) {
// Output an error if it occurred
console.error(error.message);
}
}
run below
node multiple.js > WCAGreports.txt
WCAGreports.txt will be generated for all the elements on all pages that have WCAG compliant issues.
Part of the example output report will be like below
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputText.Name',
context: '<input class="form-control" type="text" value="">',
message: 'This textinput element does not have a name available to an accessibility API. Valid names are: label element, title undefined, aria-label undefined, aria-labelledby undefined.',
type: 'error',
typeCode: 1,
selector: '#__next > div > div > div > div > form > div:nth-child(2) > div:nth-child(3) > div:nth-child(2) > div > input'
},
{
code: 'WCAG2AA.Principle1.Guideline1_3.1_3_1.F68',
context: '<input class="form-control" type="text" value="">',
message: 'This form field should be labelled in some way. Use the label element (either with a "for" attribute or wrapped around the form field), or "title", "aria-label" or "aria-labelledby" attributes as appropriate.',
type: 'error',
typeCode: 1,
selector: '#__next > div > div > div > div > form > div:nth-child(2) > div:nth-child(3) > div:nth-child(2) > div > input'
},
12.2. Chrome Inspector
To have a visual feedback of which html element has violated the WCAG rule,use Chrome Inspector
-
Download the Chrome stable version
-
Launch a page, e.g, http://localhost:8000/product/list
-
Press F12 to lauch the chome devTools
-
Go to audit tab, check the "accessibility" checkbox and press "run audit" button
-
A report is shown for the page regarding the WCAG validation and the elements that have WCAG compliance issue.
-
Click on the element, you can visually see the issue on premise.
-
After fix specific issue, rerun the audit report to verify the issue is gone
13. Pa11y-ci automation
We used pa11y-ci to automate the WCAG compliance detection and pa11y-ci-reporter-html to generate report of configured level of severity with good readability.
13.1. Follow below steps in sequence
13.1.1. Go to packages/dina-ui for object store UI ( packages/seqdb-ui for seqdb UI )
cd packages/dina-ui
13.1.2. Check compliance for all configured pages and output json format results
yarn a11y-check
13.1.3. Generate user fridendly html report based on above results
yarn a11y-generate-report
Note for linux user, due to a bug in pa11y-ci-html-reporter npm package, will need to run below first:
sudo apt install dos2unix
dos2unix /home/shemy/git/dina-ui/node_modules/pa11y-ci-reporter-html/bin/pa11y-ci-reporter-html.js
13.1.4. Know issues
Due to the pa11y implementation, in order to get all compliance level report (A, AA are the currentl AAFC scope), one will need to change the .pa11yci file to manually switch between the A and AA to cover both,as the script takes one arg only.
e.g, modify the .pa11yci file to change Standards: "WCAG2AA" to Standards: "WCAG2A", as Standards: "WCAG2AA" is the default, and WCAG2AAA is out of scope as of writting the doc.
14. Translation task
14.1. To export to csv file
-
To get the net items for translation ( instead of sending all entries in the file with some items having blank French translation), add a filter in messages-csv-export.ts as in below line:
const rows = keys.filter(key => english.messages[key] && (french.messages[key] === undefined ||
french.messages[key]?.trim().length === 0 ||
french.messages[key] === null )
).map<CsvRow>(key => ({
run
cd ./packages/scripts
yarn --silent export-intl-csv > messages.csv
-
Open the csv and save as xlsx file, make sure the xlsx is able to be previewed
-
Go to the translation site and clone the previous project (or create a new project), upload the xlsx file and submit, when translation is received or done, an email will be sent to the contact email.
14.2. To import from translated csv file
-
Go to the translation site and download the target translated file
-
Make sure the column header has
frenchinstead offrançais -
Open the file in windows excel and save it as
CSV (Comma delimited)file. -
Import the file into the
/packages/scriptdirectory.
Run the following commands:
cd ./packages/scripts
CSVFILE=messages.csv yarn import-intl-csv
-
Verify there are no unexpected characters, e.g apostrophe are the same as existing ones etc.