Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9837e96
Setup first test for e2e first page basic standard onboarding flow
May 11, 2026
e7d854a
reorganize e2e fill form helpers + add first step
May 12, 2026
abe45da
complete basic onboarding e2e test
May 13, 2026
eebe27f
fix format errors
May 13, 2026
c988b84
Add Spain scenario (and keep the first one in case of we need it)
May 15, 2026
2b2e9a0
Wait for loading before testing next page
May 15, 2026
b937c13
back to only one test for basic employee
May 15, 2026
e7b5b58
Format code
May 15, 2026
656b83b
reorganize order of filling fields
May 15, 2026
0576f5f
new email to avoid force France
May 15, 2026
f12b848
remove unusued import
May 15, 2026
344cd5b
Fix country selection bug with webkit and use specific company for th…
May 15, 2026
0b15130
fix bug with hidden button in the viewport
May 15, 2026
a819502
Fix bug when selecting country
May 18, 2026
e6bfe7a
Format
May 18, 2026
2996060
Fix CI e2e
May 18, 2026
b59c327
Refactoring way to fill forms
May 19, 2026
f633d47
Re-order forn
May 19, 2026
8b660f7
add claude.md
May 19, 2026
608b751
Improve locator robustness
May 19, 2026
dbf6701
Add logs to see if JSON Schema is the same in CI
May 19, 2026
624c52c
Debug to understand if JSON Schema is the same
May 19, 2026
7bc4f62
Remove log and overtime_compensation_method field
May 19, 2026
b28c703
remove ^= and fill experience_Level with all the text value
May 19, 2026
e8e77c0
check if feature flag works
May 19, 2026
2ddbaaf
add suggestion
gabrielseco May 20, 2026
ce8244e
remove cssID
gabrielseco May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`@remoteoss/remote-flows` is a React component library (npm package) that exposes embeddable HR/employment flows (Cost Calculator, Onboarding, Contractor Onboarding, Termination, Contract Amendment, Create Company) for Remote's platform. It is consumed by external partners, so the public API in [src/index.tsx](src/index.tsx) is contract-bound: prefer additive changes, and treat type/prop/hook signature changes as breaking.

## Commands

```bash
npm run dev # tsup watch build (link this package into example/ for live dev)
npm run build # production bundle (tsup)
npm test # vitest (jsdom). Single test: npx vitest run path/to/file.test.tsx
npm run test:coverage # coverage with thresholds from scripts/coverage-utils
npm run type-check # tsc --noEmit
npm run lint # oxlint
npm run format # oxfmt
npm run check-format # oxfmt --check (CI gate)
npm run check-exports # @arethetypeswrong/cli — verifies the package.json exports map
npm run size # bundle analysis → out/bundle-analysis.json
npm run size:check # fails if .sizelimit.json thresholds are exceeded
npm run openapi-ts # regenerate src/client/* from production gateway OpenAPI
npm run openapi-ts:local # regenerate from local gateway (openapi-ts.config.local.ts)
npm run ci # full local CI: build + check-format + check-exports + lint + type-check + test
```

The `example/` app is a separate workspace (its own `package.json`, Vite + Express dev server on `:3001`). To work against local changes: `npm link` in repo root, then `npm link @remoteoss/remote-flows` inside `example/`, then run `npm run dev` in both. E2E lives in [example/e2e/](example/e2e/) and is run with `npm run test:e2e` from `example/` (Playwright). E2E is excluded from the root vitest run.

## Architecture

### Provider chain

[src/RemoteFlowsProvider.tsx](src/RemoteFlowsProvider.tsx) is the single root every consumer wraps their flows in. It composes, outside-in: `ErrorContextProvider` → `RemoteFlowsErrorBoundary` → `QueryClientProvider` (shared `queryClient` from [src/queryConfig.ts](src/queryConfig.ts)) → `FormFieldsProvider` (merges user-provided field components with `lazyDefaultComponents`) → `RemoteFlowContext` (carries the API `Client`) → `ThemeProvider`. The API client is created once via `useRef(createClient(...))` — `auth`, `proxy`, `environment`, `credentials` are read at construction time only.

### Flow anatomy

Each flow under [src/flows/](src/flows/) is self-contained and must not import from sibling flows. Standard layout:

```
flows/<FlowName>/
index.ts # public exports (re-exported from src/index.tsx)
<FlowName>Flow.tsx # render-prop container component
<FlowName>Form.tsx # form component
<FlowName>SubmitButton.tsx / ResetButton.tsx # must be used inside the flow's render prop
hooks.tsx # headless use<FlowName>() — exposes the same bag the render prop receives
api.ts # React Query hooks / queryOptions for this flow
context.ts # flow-local context (if needed)
types.ts
components/ # step components (BasicInformationStep, ContractDetailsStep, …)
json-schemas/ # static schema fallbacks
tests/ # fixtures.ts + *.test.tsx
utils.ts
```

Flows are exposed two ways: the prebuilt `<FlowNameFlow render={...}>` component, **and** the `useFlowName()` headless hook for fully custom UIs. Both surfaces must stay in sync.

### Multi-step state

[src/flows/useStepState.ts](src/flows/useStepState.ts) is the shared step-state primitive used across multi-step flows (Onboarding, ContractorOnboarding, CreateCompany, Termination). It tracks `currentStep`, transitions, and per-step values. Consumers switch on `flowBag.stepState.currentStep.name`.

### Generated API client

`src/client/` is **fully generated** by `@hey-api/openapi-ts` from [openapi-ts.config.ts](openapi-ts.config.ts) (production gateway) or `openapi-ts.config.local.ts` (local gateway). Never hand-edit `*.gen.ts`. After schema changes, run `npm run openapi-ts` and commit the regenerated files. Import types from `src/client/types.gen.ts` and SDK functions from `src/client/sdk.gen.ts`. `.oxlintrc.json` ignores `src/client/**/*.gen.ts` from lint.

### Forms

Forms use **React Hook Form + Yup + `@remoteoss/remote-json-schema-form-kit`**. Field rendering is delegated to the `FormFieldsContext` component map; consumers override per-type renderers via the `<RemoteFlows components={...}>` prop, and built-ins come from [src/lazy-default-components.ts](src/lazy-default-components.ts) (lazy-loaded to keep the bundle small). The `flowBag` exposes `handleValidation` and `parseFormValues` — both are **async** (changed in v1.0.0, see [MIGRATION.md](MIGRATION.md)).

### React Query patterns

Two patterns coexist; pick deliberately:

- **`queryOptions` factory** (preferred when transformations vary per consumer): export `xxxOptions(client, ...)` returning `queryOptions({...})`. Consumers spread it into `useQuery`/`useSuspenseQuery`/`useQueries` and add their own `select`/`enabled`/`staleTime`. Reference: [src/common/api/countries.ts](src/common/api/countries.ts).
- **Custom hook** (preferred when the transformation is fixed and idempotent for every consumer): wrap `useQuery` directly. Reference: [src/common/api/identity.ts](src/common/api/identity.ts).

Detailed guidance lives in [.cursor/rules/react-query-abstractions.mdc](.cursor/rules/react-query-abstractions.mdc).

### Mutation error handling

All mutations go through `mutationToPromise()` and `mutateAsyncOrThrow` (the plain `mutateAsync` path is deprecated). When catching errors, **always** narrow with `isMutationError(error)` from [src/lib/mutations.ts](src/lib/mutations.ts) before accessing `normalizedErrors`, `fieldErrors`, `rawError`, `response`. Re-throw after handling.

### Theming & CSS

Tailwind v4 (`@tailwindcss/postcss`) + CSS variables. Public component classes are prefixed `RemoteFlows__` (e.g. `RemoteFlows__Button`, `RemoteFlows__CostCalculatorForm`); variants use `RemoteFlows_VariantName`. Theme tokens (`colors`, `spacing`, `borderRadius`, `font`) are injected as CSS custom properties by `applyTheme()` in [src/lib/applyTheme.ts](src/lib/applyTheme.ts). Class merging uses the `cn()` helper.

### Package exports & bundle

[package.json](package.json) ships **multiple ESM entry points** — `.`, `./internals`, `./default-components`, `./flows/*`, `./styles`, `./styles.css`, `./index.css`. Tsup builds each as a separate chunk ([tsup.config.ts](tsup.config.ts)), `react`/`react-dom` are externals, `react-hook-form` + `@hookform/resolvers` are bundled (`noExternal`). Bundle limits in [.sizelimit.json](.sizelimit.json) are enforced in CI by `.github/workflows/size-check.yml`. The `internals` entry point has **no semver guarantees**.

## Conventions worth knowing

- **Vitest globals are enabled** (`vitest.config.ts`). Do not import `describe`/`it`/`expect`/`vi`/`beforeEach` from `vitest` — use them as globals.
- **Strict equality in mocks** — use `toHaveBeenCalledWith({ exact: 'shape' })`, not `expect.objectContaining()`.
- **MSW for API mocking** in tests; handlers go via `src/tests/server.ts`, fixtures live in each flow's `tests/fixtures.ts`.
- **Tests must wrap React Query consumers** in a `QueryClientProvider` and `queryClient.clear()` between tests.
- **`no-console: warn`** in oxlint — only `console.warn` / `console.error` permitted.
- **No `any`** — `typescript/no-explicit-any: error`. Use the `$TSFixMe` type from `src/types/remoteFlows.ts` for genuine workarounds.
- **Path alias `@/*` maps to repo root** (`tsconfig.json` paths); prefer `@/src/...` imports over deep relative ones.
- **HTML from API responses is unsafe** — sanitize with `sanitizeHtml()` from `src/lib/utils.ts` (or DOMPurify) before rendering. The consumer-supplied `transformHtmlToComponents` receives **unsanitized** HTML; that's the consumer's responsibility per the README.
- **Conventional commits** drive the automated release (`npm run release` → `scripts/release.ts`). `feat:` → minor, `fix:` → patch, `BREAKING CHANGE:` footer → major. Hotfix releases go through `npm run release:fix` from a version tag (see [DEVELOPMENT.md](DEVELOPMENT.md)).
- **CHANGELOG.md and `package.json` version** are updated by the release scripts — do not bump them by hand on feature branches.

## Things that look broken but aren't

- `dist/` is checked into the working tree during dev (it's gitignored, but `prepare` runs `build`); don't be alarmed by churn there.
- `src/client/*.gen.ts` regenerates wholesale — diffs will be large after `npm run openapi-ts` runs.
- The error boundary's `useParentErrorBoundary` defaults to `true` in code but `false` in the README example; the README documents the recommended consumer config, not the default.
3 changes: 2 additions & 1 deletion example/e2e/add-estimation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { fillEstimationForm, setupVercelBypass } from './helpers';
import { setupVercelBypass } from './helpers/general';
import { fillEstimationForm } from './helpers/estimation';

test.describe('add estimation from drawer', () => {
test.beforeEach(async ({ page }) => {
Expand Down
3 changes: 2 additions & 1 deletion example/e2e/annual-gross-salary.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { fillEstimationForm, setupVercelBypass } from './helpers';
import { setupVercelBypass } from './helpers/general';
import { fillEstimationForm } from './helpers/estimation';

test.describe('annual gross salary', () => {
test.beforeEach(async ({ page }) => {
Expand Down
3 changes: 2 additions & 1 deletion example/e2e/edit-estimation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { fillEstimationForm, setupVercelBypass } from './helpers';
import { setupVercelBypass } from './helpers/general';
import { fillEstimationForm } from './helpers/estimation';

test.describe('edit estimation', () => {
test.beforeEach(async ({ page }) => {
Expand Down
22 changes: 1 addition & 21 deletions example/e2e/helpers.ts → example/e2e/helpers/estimation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Page, Route } from '@playwright/test';
Comment thread
jordividaller marked this conversation as resolved.
import { Page } from '@playwright/test';

interface FillEstimationFormOptions {
country: string;
Expand Down Expand Up @@ -26,23 +26,3 @@ export async function fillEstimationForm(
}
await page.click('.submit-button');
}

export async function setupVercelBypass(page: Page) {
await page.route('**/*', async (route: Route) => {
const url = route.request().url();

// Only add Vercel bypass headers to requests to the Vercel deployment
if (url.includes('vercel.app') || url.includes('localhost:3001')) {
const headers = {
...route.request().headers(),
'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_TOKEN || '',
'x-vercel-set-bypass-cookie': 'true',
};

await route.continue({ headers });
} else {
// For external requests (like to gateway.remote.com), continue without the headers
await route.continue();
}
});
}
178 changes: 178 additions & 0 deletions example/e2e/helpers/general.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { Page, Route } from '@playwright/test';

export async function setupVercelBypass(page: Page) {
await page.route('**/*', async (route: Route) => {
const url = route.request().url();

// Only add Vercel bypass headers to requests to the Vercel deployment
if (url.includes('vercel.app') || url.includes('localhost:3001')) {
const headers = {
...route.request().headers(),
'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_TOKEN || '',
'x-vercel-set-bypass-cookie': 'true',
};

await route.continue({ headers });
} else {
// For external requests (like to gateway.remote.com), continue without the headers
await route.continue();
}
});
}

export type inputType =
| 'textField'
| 'select'
| 'comboBox'
| 'radio'
| 'checkbox'
| 'datepicker';

export type FillFormOptions = {
type: inputType;
value?: string;
name?: string;
testId?: string;
options?: { nativeSelect?: boolean };
};

export async function fillForm(page: Page, values: FillFormOptions[]) {
for (const option of values) {
switch (option.type) {
case 'textField':
if (option.name) {
await fillTextField(page, option.name, option.value);
} else {
throw new Error('textField need name to be located');
}
break;
case 'select':
if (option.name) {
await fillSelect(page, option.value, option.name, option.options);
} else {
throw new Error('select need name to be located');
}
break;
case 'comboBox':
if (option.name) {
await fillComboBox(page, option.value, option.name);
} else {
throw new Error('comboBox need name to be located');
}
break;
case 'radio':
if (option.name) {
await fillRadio(page, option.value, option.name);
} else {
throw new Error('radio need name to be located');
}
break;
case 'checkbox':
if (option.name) {
await fillCheckbox(page, option.value, option.name);
} else {
throw new Error('checkbox need name to be located');
}
break;
case 'datepicker':
if (option.testId) {
await fillDatepicker(page, option.value, option.testId);
} else {
throw new Error('DatePicker need testId to be located');
}
break;
default:
throw new Error(`Unsupported input type: ${option.type}`);
}
}
}

export async function fillTextField(
page: Page,
name: string,
value: string = '',
) {
await page.locator(`[data-field="${name}"] :is(input, textarea)`).fill(value);
}

export async function fillSelect(
page: Page,
value: string = '',
name: string,
options: { nativeSelect?: boolean } = { nativeSelect: false },
) {
if (options.nativeSelect) {
const dropdown = page.locator(`[data-field="${name}"] select`);
await dropdown.waitFor({ state: 'visible' });
await dropdown.selectOption(value);
} else {
const dropdown = page.locator(`[data-field="${name}"]`);
await dropdown.click();
const option = page.getByRole('option', { name: value });
await option.waitFor({ state: 'visible' });
await option.dispatchEvent('click');
}
}

export async function fillComboBox(
page: Page,
value: string = '',
dataField: string,
) {
await page
.locator(`[data-field="${dataField}"]`)
.getByRole('combobox')
.click();
const categoryOption = page.getByRole('option', {
name: value,
});
await categoryOption.waitFor({ state: 'visible' });
await categoryOption.click();
}

export async function fillRadio(
page: Page,
value: string = '',
dataField: string,
) {
const locator = page.locator(
`[data-field="${dataField}"] button[role="radio"][value="${value}"]`,
);
await locator.waitFor({ state: 'visible' });
await locator.click();
}

export async function fillCheckbox(
page: Page,
value: string = '',
dataField: string,
) {
const locator = page.locator(
`[data-field="${dataField}"] button[role="checkbox"]`,
);
await locator.waitFor({ state: 'visible' });
await locator.click();
}
Comment thread
cursor[bot] marked this conversation as resolved.

export async function fillDatepicker(
page: Page,
value: string = '',
testId: string,
) {
await page.getByTestId(testId).click();
if (value === 'auto') {
await page
.locator('button[role="gridcell"]:not([disabled])')
.first()
.click();
} else {
await page
.getByRole('button', {
name: value,
exact: true,
})
.and(page.locator(':not([disabled])'))
.first()
.click();
}
}
Loading
Loading