...
Install the alpha branch of frontend build
npm install @edx/frontend-build@alpha
Add a
tsconfig.json
file to the top-level of your appCode Block language json { "extends": "@edx/typescript-config", "compilerOptions": { "rootDir": ".", "outDir": "dist", "baseUrl": "./src", "paths": { "*": ["*"] } }, "include": ["src/**/*"], "exclude": ["dist", "node_modules"] }
Update commitlint job (
.github/workflows/commitlint.yml
) to remove thetsconfig
when runningSee issue https://github.com/conventional-changelog/commitlint/issues/3256
Code Block language yaml # Run commitlint on the commit messages in a pull request. name: Lint Commit Messages on: - pull_request jobs: commitlint: runs-on: ubuntu-20.04 steps: - name: Check out repository uses: actions/checkout@v3 with: fetch-depth: 0 - name: remove tsconfig.json # see issue https://github.com/conventional-changelog/commitlint/issues/3256 run: | rm -f tsconfig.json - name: Check commits uses: wagoid/commitlint-github-action@v5
...
Code Block | ||
---|---|---|
| ||
// .../api-src/types.ts export interface UserData { ...myUserDataTypeDefinition } export interface SystemData { ...mySystemDataTypeDefinition } export interface SubmitEventData { ...mySubmitEventDataTypeDefinition } |
...
2:
...
Add query types for react-query
queries.
Note: react-query
does provide exported types here, but in general I found those a bit deeper/more complex than needed for this basic usage. In particular, I am relying on a snake_case upstream data source and converting it into camelCase for consumption. The TS wrapping of the useQuery
calls in this pattern is not optimized for that specific interface, so much as it is for the consuming selectors.
Code Block | ||
---|---|---|
| ||
// .../api-src/types.ts
...
// React-Query fields
export interface QueryStatus {
isLoading: boolean,
isFetching: boolean,
isInitialLoading: boolean,
error: unknown,
status: string,
}
export interface QueryData<Response> extends QueryStatus {
data: Response,
} |
3: Define API hooks
...
language | js |
---|
...
Define API hooks
Code Block | ||
---|---|---|
| ||
// .../api-src/hooks/api.ts import { useQuery, useMutation } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform'; // or lodash import * as types from '../types'; import { queryKeys } from '../constants'; export const useUserData = (userId: string): types.QueryData<types.UserData> => { const { data, ...status } =( useQuery({ queryKey: [queryKeys.userData, userId], queryFn: () => fetch(`my-urls/user-data/${userId}`), }); return { ...status, data: data ? camelCaseObject(data) : {} }; } .then(camelCaseObject), }) ); export const useSystemData = (): types.QueryData<types.SystemData> => { const { data, ...status } =( useQuery({ queryKey: [queryKeys.systemData], queryFn: () => fetch('my-urls/system-data'), }); return { ...status, data: data ? camelCaseObject(data) : {} }; }then(camelCaseObject), }); ); export const useSubmit = () => useMutation({ mutationFn: (data: types.SubmitEventData), }); |
...
3: Define Selectors for the API hooks
Code Block | ||
---|---|---|
| ||
// ...api-src/hooks/selectors.ts import * as api from './api'; import * as types from './types'; // generic accessor export const useUserDataStatus = (userId: string): types.QueryStatus => { const { isLoading, isFetching, isInitialLoading, status, error, } = api.useUserData(userId); return { isLoading, isFetching, isInitialLoading, status, error, }; }; export const useIsUserDataLoaded = (userId: string): boolean => ( api.useUserData().status === 'success' ); export const useUserDataResponse = (userId: string): types.UserData => ( api.useUserData().data ); /* * add more, increasingly granular UI selectors here, until all "questions" * asked about the data by the UI for presentation are answered in this module. */ |
...
4: Consume (Profit!)
When consuming data, ALWAYS consume with selectors. The reasoning is that this separates your UI code from the raw details of the incoming data. At all times, the consumers of the data should be asking “Questions” that can be moved around and fixed, even if the incoming data shape changes.
...
Does it call
useQuery
with the correctqueryKey
array?Does it call
queryFn
useQuery
with the expected fetchqueryFn
method?Does it return the status fields from they query?
Does it return camel-case’d data response from the query (or
{}
if empty)?
...
Code Block | ||
---|---|---|
| ||
// ...api-src/hooks/api.test.ts import { useCamelCaseObject } from '@edx/frontend-platform'; import { when } from 'jest-when'; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn() useMutation: jest.fn(({ mutationFn }) => ({ mutationFn })), })); const mockUseQueryForUserData = (response) => { when(useQuery) .calledWith(expect.objectContaining({ queryKey: [queryKeys.userData] })) .mockImplementation(({ queryKey, queryFn }) => ({ data: response, queryKey, queryFn, }); } let out; const userId = 'test-user-id'; const response = { test-field: 'user-response' }; describe('api hooks', () => { describe('useUserData', () => { beforeEach(() => { mockUseQueryForUserData(response); out = hooks.useUserData(userId); }); // Does it call useQuery with the correct queryKey array? it('initializes query with userData queryKey and user ID', () => { expect(out.queryKey).toEqual([queryKeys.userData, userId]); }); // Does it call queryFn with the expected fetch method? it('initializes query with <my get behavior>', () => { // Mock/test your fetch method by calling out.queryFn() });; // Does it return the status fields from they query? -- Add status test from repo // Does it return thecamel-case’d statusdata fieldsresponse from theythe query (or {} if empty)? it('returns camelCase object from data if data has been returned', () => { expect(out.data).toEqual(camelCaseObject(response)); }); // Does it return camel-case’d data response from the query (or {} if empty)? it('returns empty object from data if data has not been returned', () => { mockUseQueryForUserData(undefined); out = hooks.useUserData(userId); expect(out.data).toEqual({}); }) }); // ...test system data in similar pattern. }); |
...