Primary's two main web applications: the Ops Portal and the Client Portal. These are not currently served separately.
View this documentation at primary-apps-docs.pages.dev.
npm install.env file to fill in secret gaps.npm run docker-db to start a local Postgres and Valkey.npm run setup to seed and migrate the database.npm run dev to start the dev server.npm run storybook to develop UIs with Storybook.Firstly, you will need to grab a .env file for local development. See .env.example for what this should look like. Ask another dev for their .env file.
Additionally, you will want to get access to these 3rd party services. Contact someone in the eng team to grant access via Google auth to these dashboards -
nvm, mise-en-place or asdf. See .nvmrc for the required version, use node --version to verify.Tip: go through these commands in package.json to see exactly what they are doing.
npm run docker-db
Note: The npm script will complete while Docker sets up the container in the background. Ensure that Docker has finished and your container is running before proceeding. The database will continue to run in the background, so you shouldn't need to re-run this command often.
npm run setup
This will install dependencies, generate the Prisma client libraies, run any outstanding DB migrations (or all of them for the first time), and populate the local DB with seed data.
This should only be needed when:
npm run dev
This starts your app in development mode with hot module reload. This additionally starts the Graphile Worker process.
Our tests are broken down into these categories:
npm run test - unit tests run with vitest. Files ending in .test.ts or .test.tsx will be run.npm run test:integration - unit tests which also hit the local database, also run with vitest. Files ending with
.integration.test.ts.This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run npm run typecheck.
This project uses ESLint for linting. That is configured in .eslintrc.js.
We use Prettier for auto-formatting in this project. It's recommended to install an editor plugin (like the VSCode Prettier plugin) to get auto-formatting on save. There's also a npm run format script you can run to format all files in the project.
We use dependency-cruiser to enforce boundaries within the project.
See .dependency-cruiser.js for the complete ruleset.
We should aim to fix any violations as they arise, though you can add violations to the known violations list in
exceptional cases by running npm run depcheck:baseline, and committing the resulting changes.
We use Playwright for end-to-end testing.
To run the tests, you can use npm run test:e2e.
You can also use npm run test:e2e -- --ui to debug them visually.
These are also run in CI on every PR.
npx playwright show-trace test-results/smoke-Top-level-route-smok-142ce-rtal-admin-transactions-new-chromium-retry1/trace.zip. This will show you a complete trace, including network requests, console errors, and screenshots.We use Storybook as a frontend development tool, and to document and catalog components, blocks, and pages.
To start Storybook, run npm run storybook.
Storybook previews are deployed on every PR which can be used by reviewers to experience changes.
Tests, linting, formatting, dep cruiser check and Typescript check are all required to pass before merging is enabled in GitHub.
The exact set of commands useds can be found in .github/workflows/pr-ci.yml.
We use GitHub Actions for continuous integration and deployment. See .github/workflows/deploy.yml to see exactly what happens on merge to main. In short, the changes will be build and deployed first to the Staging environment, if successful it will be immedaitely deployed to Production.
This repo also contains a number of CLI commands used for ad-hoc tasks (e.g. data backfill or debugging). These are
located in app/commands, and can be run using npm run dev-cmd <command name>.
E.g. backfilling ledger transactions
npm run dev-cmd backfill-ledger-transactions -- --actor test --dataFile ./data/backfill-ledger-transactions/dev/test.json
See each command for usage.
schema.prismanpx prisma format to format the prisma schemanpx prisma migrate dev --create-only to generate a migration filenpm run setup to generate types, update the db schema, and rerun seednpx prisma studio to look at your datanpx prisma seed to run just the seed script (with the overhead of npm run setup).See the Prisma CLI documentation.
cn when composing classNamestailwind.config.mtstailwind-variants for complex componentsclassName overrides100dvh, not 100vhfunction over const for React componentsWe use Clerk for authentication, user management, user organisations, roles, permissions, and authorisation.
Clerk allows up to 50 custom permissions to be defined per application. These
permissions should be defined in Clerk, then added to our global typing in
types/clerk.d.ts. From there, they can be used in our codebase to check if
a user has a particular permission.
Authorisation checks should always check that a subject (in our case, a user) has a particular permission. Do not check that the user is a member of a particular role. Roles can change, as can the permissions associated with those roles. We want to enable making those changes in future without having to audit all existing authorisation checks in the codebase to ensure that they are still appropriate.
There is a custom session claim, primary_ops, which can be used to check
whether a user should have access to the operator portal or not. This is not
ideal. In future, the ops portal will be a separate application in Clerk with
its own roles and permissions.
Since our app is multi-tentanted, it is imperative that all resources are explicitly checked against the current
user. The Identity pattern (see identity.server.ts) provides a simple way to do this.
// route.tsx
export const loader = async (loaderArgs: LoaderFunctionArgs) => {
const { identity } = await requireUserAndOrganisation({ loaderArgs });
await myServiceFunction({ context: { identity } });
};
// myService.server.ts
const myServiceFunction = async ({ context }: { context: IdentityContext }) => {
// to check access to a single organisation
requireOrganisationAccess(identity, organisationId);
// to check access to a all organisations (relevant to admin/ops portal users)
requireAllOrganisationAccess(identity);
};
We expect that tenancy checks are done at the service level, as per the above example.
Return 404 if the user doesn't have the right permissions.
It is Clerk's convention to return 404 (not found) when a request is deemed unauthorized with respect to permissions or role. https://clerk.com/docs/references/nextjs/auth#protect
I suppose this is according to a "you shouldn't know about what you can't see" philosophy. However, it can be counterintuitive during development. You may sometimes think "Huh? am I running the right code? Restarted server? Is the request URL incorrect?"
import { formAction } from "~/form-action.server";
const schema = z.object({
greeting: z.string(),
});
export const action = async (actionArgs: ActionFunctionArgs) => {
return formAction({
//...other props
schema,
//...
});
};
export const View = () => {
const { greeting } = useActionData<typeof action>();
// render things
};
export default View;
action: ActionFunction as it is not generic and can't infer the response type from your return statements. Use actionArgs: ActionFunctionArgs as the first parameter instead.Generally:
Use <Form>, causing a navigation and full page reload, when there will be significant changes to the frontend state after submission, such as creating or deleting a record.
Use <fetcher.Form>, an async fetch request, for actions that cause easy to reason about mutations, such as updating a field in a record.
Heres an excerpt from the Remix docs that explains this well:
These actions typically reflect significant changes to the user's context or state:
Creating a New Record: After creating a new record, it's common to redirect users to a page dedicated to that new record, where they can view or further modify it.
Deleting a Record: If a user is on a page dedicated to a specific record and decides to delete it, the logical next step is to redirect them to a general page, such as a list of all records.
For these cases, developers should consider using a combination of
<Form>,useActionData, anduseNavigation. Each of these tools can be coordinated to handle form submission, invoke specific actions, retrieve action-related data, and manage navigation respectively.
These actions are generally more subtle and don't require a context switch for the user:
Updating a Single Field: Maybe a user wants to change the name of an item in a list or update a specific property of a record. This action is minor and doesn't necessitate a new page or URL.
Deleting a Record from a List: In a list view, if a user deletes an item, they likely expect to remain on the list view, with that item no longer in the list.
Creating a Record in a List View: When adding a new item to a list, it often makes sense for the user to remain in that context, seeing their new item added to the list without a full page transition.
Loading Data for a Popover or Combobox: When loading data for a popover or combobox, the user's context remains unchanged. The data is loaded in the background and displayed in a small, self-contained UI element.
For such actions,
useFetcheris the go-to API. It's versatile, combining functionalities of the other four APIs, and is perfectly suited for tasks where the URL should remain unchanged.
https://remix.run/docs/en/main/discussion/form-vs-fetcher#specific-use-cases
For example: https://primary-storybook.pages.dev/?path=/docs/alertv2--docs
app/ui/dashboard/Alert/AlertV2.stories.tsx
Parents should define layout and spacing between elements, whereas children/components shouldn't decide how much space they should have around them. This makes it possible to compose components with layouts, without hacks or overrides. https://youtu.be/jnV1u67_yVg?t=620
function AccountCard({
name,
bsb,
number,
}: {
className?: string;
name;
bsb;
number;
}) {
return (
<div
className={cn(
"p-2 grid gap-2 bg-white rounded-md border shadow-md",
className
)}
>
<h2>{name}</h2>
<p>
{bsb} {number}
</p>
</div>
);
}
function AccountList({ accounts }: { accounts: Account[] }) {
return (
<div className="grid gap-4">
{accounts.map((account) => (
<AccountCard key={account.id} {...account} />
))}
</div>
);
}
// Or
function AccountListWithPrimaryAccount({ accounts }: { accounts: Account[] }) {
return (
<div className="grid">
{accounts.map((account, i) => (
<AccountCard
className={i === 0 ? "mb-8" : i !== accounts.length - 1 ? "mb-4" : ""}
key={account.id}
{...account}
/>
))}
</div>
);
}
Parents should define spacing between elements.
This can be, preferrably, through grid or flex features.
However, in AccountListWithPrimaryAccount for example, a variable margin is set on the AccountCard, and it is still the parent that decides the spacing between elements.
function AccountCard({
name,
bsb,
number,
}: {
className?: string;
name;
bsb;
number;
}) {
return (
<div
className={cn(
"mb-4 p-2 grid gap-2 bg-white rounded-md border shadow-md",
className
)}
>
<h2>{name}</h2>
<p>
{bsb} {number}
</p>
</div>
);
}
function AccountList({ accounts }: { accounts: Account[] }) {
return (
<div className="grid">
{accounts.map((account) => (
<AccountCard key={account.id} {...account} />
))}
</div>
);
}
function ListItem({ children }) {
return <li className="flex items-center">{children}</li>;
}
export function AccountDetails({ payId, bsb, number }) {
return (
<ul className="list-disc list-inside">
<ListItem>Account number: {number}</ListItem>
<ListItem>BSB: {bsb}</ListItem>
<ListItem>PayID: {payId}</ListItem>
</ul>
);
}
export function AccountDetails {
return (
<ul className="list-disc list-inside">
<li className="flex items-center">Account number: {this.props.number}</li>
<li className="flex items-center">BSB: {this.props.bsb}</li>
<li className="flex items-center">PayID: {this.props.payId}</li>
</ul>
)
}
cn when composing classNamesThis merges tailwind classes appropriately and consistently.
import { cn } from "~/lib/utils";
function AccountCard({
name,
bsb,
number,
}: {
className?: string;
name: string;
bsb: string;
number: string;
}) {
return (
<div
className={cn(
"p-2 grid gap-2 bg-white rounded-md border shadow-md",
className
)}
>
<h2>{name}</h2>
<p>
{bsb} {number}
</p>
</div>
);
}
tailwind.config.mtsUsing arbitrary z-index values throughout our codebase can make it hard to to be predictable about the 3rd dimension in our UI.
function Sidebar() {
return <nav className="z-sidebar"></nav>;
}
tailwind-variants for complex componentsYou'll need this when switching styles based on props.
import type { VariantProps } from "tailwind-variants";
import { tv } from "tailwind-variants";
const alertStyles = tv({
base: "p-4 rounded-xl border",
// When feasible, try to match variants to their matching Figma components.
variants: {
kind: {
default: "bg-gray-100 border-gray-500",
success: "bg-green-100 border-green-500",
error: "bg-red-100 border-red-500",
warning: "bg-yellow-100 border-yellow-500",
},
},
defaultVariants: {
kind: "default",
},
});
// Note the use of `VariantProps` for extracting prop types from above.
export function Alert({
kind,
children,
}: { children: React.ReactNode } & VariantProps<typeof alertStyles>) {
return <div className={alertStyles({ kind })}>{children}</div>;
}
See app/ui/dashboard/Alert/AlertV2.tsx for a larger example, including the use of slots.
className overridesWhen overriding any styles using className, ask yourself how hard it would be for a stranger to make internal style
changes to the component without visual regressions in your instance.
function DashboardPage() {
return (
<div className="grid min-h-dvh">
{/*
This is safe because place-self only affects it's position within the parent layout.
Using something like `flex` or `grid` would be dangerous.
*/}
<AccountsList className="m-4 place-self-center" />
{/*
`fixed` may be a dangerous override because the element may be
assuming a `relative` root, for positioning children within it.
It it most likely safer to wrap the element.
*/}
<div className="fixed bottom-4 right-4 h-52 w-52">
<DevToolsOverlay className="h-full w-full" />
</div>
</div>
);
}
function DashboardPage() {
return (
<div className="grid min-h-dvh">
{/*
Padding isn't safe beause the AccountsList could be setting a border or shadow.
It shouldn't be the parent's responsibility to know how to style the accounts list, so `bg-black`
doesn't make sense here.
Rather, it indicates you may need to add another variant to AccountsList.
*/}
<AccountsList className="m-4 bg-black p-2" />
<DevToolsOverlay className="fixed bottom-4 right-4 h-52 w-52" />
</div>
);
}
100dvh, not 100vhWhen you need something to span the height of the viewport.
dvh is more reliable because it is the "safe" area of the screen; it respects extraneous elements in
all browsers, e.g. the address bar in some mobile browsers.
It is dynamic; it updates when the viewport changes.
Note: dvh isn't for every use case.
E.g. when you don't want it's value to change upon resizing the viewport.
https://learnbricksbuilder.com/demystifying-vh-dvh-svh-and-lvh-in-css/
function over const for React componentsUse const when you need, but prefer function.
function MyComponent() {
return <div>My component</div>;
}
const MyComponent = () => {
return <div>My component</div>;
};
When a date range is specified, variables should be named to indicate inclusivity or exclusivitiy explicitly, e.g.
startAtInclusive and endAtExclusive.
In some cases, inclusivity/exclusivity may be parametried, for example:
type IntervalBoundary = "inclusive" | "exclusive";
{
startAt: Date;
endAt: Date;
options: {
endAtBoundary: IntervalBoundary;
startAtBoundary: IntervalBoundary;
}
}
For me, it's "Shift + Cmd + E" in VSCode. Doing this often will save you headaches when trying to merge a PR, since it will be blocked if there are linting errors.
Similar to the above, this will clean your imports and help you pass PR checks.
In the context of:
In the context of:
casbinaccesscontrolcasl@tselect/access-controlimport { redirectWithSuccess, redirectWithError } from "remix-toast";
import { performMutation } from "remix-forms";
// in your action function:
const result = await performMutation({
request,
schema: someSchema,
mutation: someMutation,
environment: { userId, orgId },
});
if (result.success) {
return redirectWithSuccess(`/dashboard/payouts/${payoutId}`, {
message: "Payout approved",
description: `Optional description`,
});
} else {
return redirectWithError(`/dashboard/payouts/${payoutId}`, {
message: "Falied to approve payout",
});
}
remix-toast is setup in app/routes/dashboard.tsx.
Internally, it uses session.flash to set a temporary cookie that is unset after reading https://remix.run/docs/en/main/utils/sessions#sessionflashkey-value.
export const loader = async (loaderArgs: LoaderFunctionArgs) => {
const { auth } = await requireUserAndOrganisation({ loaderArgs });
const canManagePayouts = auth.has({ permission: "org:payouts:manage" });
// ...
};
Calling requireUserAndOrganisation checks that you're logged in and have an organisation selected.
It also returns an auth object from Clerk's library, which has orgPermissions: string[], or a convenient has function to check either role or permission existence.
VITE_ENABLE_REACT_SCAN to true in your .env file to enable React Scan.