This project is licensed under the Apache License 2.0. By submitting a contribution (for example, a pull request) for inclusion in this repository, you agree that your contribution is provided under the Apache License 2.0 — consistent with Section 5 of that license — and you represent that you have the right to submit it under those terms. A signed Contributor License Agreement (CLA) may be required for contributions going forward; we'll update this file if and when that process is in place.
To install dependencies:
npm installTo run:
npm run devIf you just need to develop a component outside of its context within a workflow, you can use Storybook:
npm run storybookIf you need to test your component within the larger context of a workflow, you'll need to setup Zenpayroll + GWS-Flows.
-
Follow setup instructions for Zenpayroll and start local dev server
- In another tab run the following commands in Zenpayroll directory(these generate necessary partner account for
gws-flows) :
bundle exec rails runner "DevAccountCreator.new.create_dev_accounts" rake partners_api:dev_setup_for_gws_onboarding
- In another tab run the following commands in Zenpayroll directory(these generate necessary partner account for
-
Follow setup instructions in the readme for GWS Flows
-
in SDK, Run
npm run dev:setup; if that fails, follow the instructions below to setup manually:- Run
npm link ../gws-flows/node_modules/reactin sdk folder - this is needed to avoid duplicate react instances in local development due to using npm/yarn link - In GWS-Flows project folder, run
yarn link -r ../embedded-react-sdk
- Run
-
In SDK, run
npm run dev
Now your local changes appear in GWS Flows.
To see the SDK running in GWS Flows, visit it locally and choose React SDK (New company) or React SDK (Company Onboarded) under Select a Type and click Create Demo
Components and Flows will be shown at the top of the page in a nav. Company Components will automatically appear as they are added.
Select a Flow or Component to view it
Translations are stored in /src/i18n.
The are broken out by locale (e.g. en), then by namespace, which is usually the name of the component they are used in. The source of truth is the en locale - the rest will be autogenerated by translation service.
After writing the new translations, run npm run i18n:generate to regenerate the checked-in src/i18n/types.d.ts (the Translations namespace and Resources map that type i18next and back the browsable Translations reference). Commit the regenerated file alongside your JSON — CI's derive-check-i18n check fails if it is stale.
In your components, first use useI18n hook, which takes in component namespace and loads appropriate resource file into dictionary. After that use the useTranslation hook to access the translations in that component and any child components.
Follow these conventions when creating or modifying translation keys:
Use camelCase for all translation key names:
{
"pageTitle": "Federal Tax Information",
"federalEinLabel": "Federal EIN",
"federalEinDescription": "Your company's Federal Employer Identification Number",
"continueCta": "Continue"
}Use consistent suffixes to indicate the purpose of each key:
Cta- Call-to-action buttons (e.g.,submitCta,continueCta,cancelCta)Label- Form field labels (e.g.,firstNameLabel,emailLabel)Description- Help text or descriptions (e.g.,federalEinDescription)Title- Page or section headings (e.g.,pageTitle,sectionTitle)Placeholder- Input placeholders (e.g.,emailPlaceholder)Error- Error messages (e.g.,requiredFieldError,invalidEmailError)
Group related keys using nested objects for better organization:
{
"validations": {
"firstName": "First name is required",
"lastName": "Last name is required",
"email": "Email address is required and must be valid",
"address": {
"street1": "Street address is required",
"city": "City is required",
"state": "State is required"
}
},
"labels": {
"openMenu": "Open menu",
"menuLabel": "Menu"
},
"alerts": {
"progressSaved": "Your progress has been saved",
"errorEncountered": "There was a problem with your submission"
}
}Common grouping patterns:
validations- Form validation error messageslabels- Accessibility labels and field labelsalerts- Alert and notification messagesicons- Icon accessibility labelstable- Table-related labels (column headers, actions)
Only use snake_case when required by external contracts:
- API Enum Values - When keys match backend API responses:
{
"onboardingStatus": {
"admin_onboarding_incomplete": "Admin-onboarding Incomplete",
"self_onboarding_invited": "Self-onboarding: Invited"
}
}- i18next Pluralization - Required by i18next for plural forms:
{
"priority_one": "{{count}}st",
"priority_two": "{{count}}nd",
"priority_few": "{{count}}rd",
"priority_other": "{{count}}th"
}- Programmatic Identifiers - When used as flow/step identifiers that match API:
{
"stepTitles": {
"add_addresses": "Add company addresses",
"federal_tax_setup": "Company federal tax information"
}
}Good:
{
"pageTitle": "Employee Profile",
"continueCta": "Continue",
"cancelCta": "Cancel",
"validations": {
"firstName": "First name is required",
"emailFormat": "Email must be valid format"
},
"labels": {
"personalInfo": "Personal Information"
}
}Avoid:
{
"page_title": "Employee Profile", // ❌ Don't use snake_case
"ContinueCTA": "Continue", // ❌ Don't capitalize suffix
"validation_first_name": "First name...", // ❌ Don't use snake_case
"first_name_validation": "First name...", // ❌ Poor grouping
"lbl_personal_info": "Personal Info" // ❌ Don't abbreviate
}Source strings and reviewed translations flow to and from Lokalise via two workflows:
- Push Sources — runs automatically on every push to
mainthat touchessrc/i18n/en/**.json. Pushes theensource strings to Lokalise. No PR involved, so it's not affected by anything below. - Pull Translations — manual (
workflow_dispatchonly, run a few times a month as translations get reviewed in Lokalise). Downloads reviewedes_USstrings and opens a PR (branchlokalise-update-<hash>, labeledlokalise-translation) viaGusto/embedded-i18n-actions/pull_translations.
Both workflows, plus check-for-manual-translations (below), live in Gusto/embedded-i18n-actions and this repo's .github/workflows/.
The PR the workflow opens needs one manual step before it can merge — ci.yaml's required checks (build, format, lint, etc.) never run on it automatically. Root cause: ci.yaml only triggers on push/merge_group, and the PR's branch is pushed using the default GITHUB_TOKEN — GitHub's built-in anti-recursion rule means a GITHUB_TOKEN-authenticated push never triggers other push-triggered workflows. (We tried minting a GitHub App token instead to work around this — see #2680 — but the app available to this repo only has contents: write, not pull_requests: write, so PR creation itself failed outright. Reverted in #2706. Fixing this for real needs either that app's permissions extended or a dedicated PAT — both blocked on org/app-admin access at the time of writing. If you have that access, revisiting this is worthwhile so the step below can be removed.)
To merge a Pull Translations PR:
-
Trigger the workflow: Actions tab → Pull Translations → Run workflow.
-
Wait for the
lokalise-update-<hash>PR to appear (labeledlokalise-translation). -
Check out the branch locally and push it yourself:
git fetch origin git checkout lokalise-update-<hash> git merge origin/main # or rebase — either works, see note below git push origin lokalise-update-<hash>
A push made with your own credentials isn't subject to the anti-recursion rule, so it fires
ci.yamlnormally. If the branch is already up to date withmain(git mergereports "Already up to date"), there's nothing to merge — push an empty commit instead so apushevent still fires:git commit --allow-empty -m "chore: trigger CI" && git push. -
Check
format. The Lokalise CLI export doesn't add a trailing newline to the downloaded JSON files, which Prettier requires —formatwill fail on any file Lokalise actually changed. Runnpx prettier --writeon the flagged files, commit, and push again:npx prettier <path-to-flagged-file> --write git add <path-to-flagged-file> git commit -m "style: add trailing newline to Lokalise-exported translation files" git push
-
Once all required checks pass, review and merge as usual.
A separate check (.github/workflows/check-manual-translations.yaml) blocks manual hand-edits to src/i18n/es_US/** in any PR — those files should only ever be updated by the Pull Translations workflow. It skips validation when the lokalise-translation label is present (checked live via gh pr view, not the webhook event payload, which is stale by the time these bot-created PRs are evaluated — see #2674 if this check ever regresses). This check is already fixed and working; it's unrelated to the manual step above.
Block components are focused, reusable components that serve specific functionality. They follow these patterns:
- Single Purpose: Each component should generally handle a specific task (e.g., list, form, etc.)
- Base Component: Uses BaseComponent for consistent behavior and error handling
- Compound Pattern: Exposes subcomponents (Head, List, Actions) for flexibility and composition
Flow components compose block components and other flow components together using state machines to manage transitions. They follow these patterns:
- State Management: Uses the Flow component with a state machine to handle transitions
- Component Composition: Can compose both block components (e.g., list → form) and other flow components
- Naming: Suffix with "Flow" (e.g.,
DocumentSigner,Locations)
For example, EmployeeOnboardingFlow composes both block components (profile, taxes) and other flow components (document signer) to create a complete onboarding experience.
You can run the test suite locally with the following command:
npm run testEvery test that renders a component is automatically checked for accessibility violations — a global afterEach hook (src/test/setup.ts) runs axe against the rendered DOM before cleanup. You don't need to add anything to a new test to get this coverage, and CI fails on any violation (zero-tolerance threshold) since this runs inside the same test:ci job that already gates PRs.
The global check uses a relaxed rule set (see INTEGRATION_TEST_DISABLED_RULES in src/test/accessibility.ts) that ignores page-level rules like region and heading-order — an isolated component rendered outside a real page layout will always trip those, since it has no surrounding landmarks or document title to check against.
Components with dedicated accessibility test cases can still opt in to a stricter, narrower check via expectNoAxeViolations(container) (from src/test/accessibility.ts, available globally in tests) scoped to just that component's render output — this catches real issues like missing landmark roles or bad heading order that the relaxed global check intentionally skips. Keep these where they already exist; they're checking something the global gate doesn't.
If a violation is a genuine false positive rather than a real bug, disable only that specific rule with a comment explaining why (following the existing entries in DEFAULT_DISABLED_RULES/INTEGRATION_TEST_DISABLED_RULES) — don't reach for a broader escape hatch.
When creating a pull request, use the provided PR template (.github/PULL_REQUEST_TEMPLATE.md). Here are the guidelines:
Use conventional commits format for your PR title. PR titles are validated by CI. When a release is prepared with npm run release, commit history (which uses squash-merge PR titles) determines the version bump and populates the changelog — so PR title format directly affects how releases are documented.
Note: Commitlint runs in two places: as a pre-commit hook (via husky) validating individual commit messages, and as a CI check validating the PR title, which becomes the squash merge commit message.
PR titles determine the version bump and changelog entry when a release is prepared, following semver.org specification:
During 0.x.x (pre-1.0 development):
*Per semver spec, during 0.x.x the API is considered unstable, so breaking changes bump MINOR instead of MAJOR. The jump to 1.0.0 will be an intentional decision when we're ready to declare a stable API.
feat: add new component- New feature → MINOR bumpfix: resolve issue with form validation- Bug fix → PATCH bumpfeat!: redesign JSX component props- Breaking change → MINOR bump (during 0.x.x)chore: update dependencies- Maintenance → no version bumprefactor: simplify state machine logic- Code refactoring → no version bumpdocs: update README- Documentation → no version bump
For work tied to a Jira ticket, include the ticket in the scope so the title still follows conventional commits:
feat(SDK-123): add payroll blocker alerts- MINOR bumpfix(SDK-456): correct date formatting- PATCH bump
- Summary: Brief description of what the PR does and why
- Changes: Briefly list only the most important changes (high-level only; do not enumerate every file-level change)
- Demo: Screenshots or screen recordings showing the changes (when applicable)
- Related: Links to Jira tickets, Figma designs, or related PRs
- Testing: Instructions for reviewers to test the changes
- Keep PRs focused on a single concern when possible
- Include visual demos (screenshots/videos) for UI changes
- Link to relevant Jira tickets using the format
[SDK-XXX](https://gustohq.atlassian.net/browse/SDK-XXX) - Add Storybook stories for new components
- Include unit tests for new functionality
- Run
npm run testandnpm run lintbefore submitting
All commits must follow commitlint enforced format: type(scope?): subject #scope is optional; multiple scopes are supported (current delimiter options: "/", "\" and ",")
Following types are currently defined:
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test'
Read more about conventional commits here
You can find documentation on building with the Gusto Embedded React SDK in the docs directory within this repo.
Releases are prepared with release-it, which reads commits since the last release, proposes a semver bump, updates package.json and CHANGELOG.md, and creates a release branch ready for review.
From a clean main branch:
npm run releaserelease-it will:
- Show you the commits since the last release grouped by type
- Propose a version based on conventional commit prefixes (
feat→ MINOR,fix→ PATCH,feat!/fix!→ MINOR during 0.x.x) - Ask you to confirm or override the target version
- Bump
package.json, updatepackage-lock.json, and prepend a new section toCHANGELOG.md - Commit the changes and check out a
chore/release-<version>branch automatically
Then push and open a PR:
git push -u origin chore/release-<version>
gh pr create --title "chore: release <version>"Trigger the Prepare Release workflow from the Actions UI (Run workflow). It accepts an optional version override; if left blank it auto-detects from commits. The workflow creates the branch, commits the changes, and opens a PR automatically.
PRs that change the root package.json version are checked by release-readiness, regardless of branch name or PR title. Update the branch with the latest main, refresh the changelog, then get approval from another maintainer on the final commit. Any subsequent commit, including a merge from main, requires fresh approval. PRs without a version change pass this check without additional review requirements.
The check updates independently of the build when a PR or review changes, or when main advances. Merge queue checks use approvals of the original PR commits. To refresh the result manually, run Release Readiness from main.
After a release PR merges to main with a chore: release commit and CI passes, Publish to NPM starts automatically. The workflow uses npm-release to publish to npm, create a Git tag and GitHub Release with changelog notes, and send the release notification.
The intended configuration for npm-release allows workflows from main without a separate publishing approval once release-readiness is enforced. During rollout, keep the existing publishing approval until the new check is verified and required.
Use Publish RC to NPM for a one-off build from a branch or commit. Run the workflow from main and choose the source using its inputs. It publishes under rc, leaving latest unchanged, with no Git tag or GitHub Release.
When finished, use Retire RC, supplying the version currently tagged rc. It removes the tag; exact-version installs remain available. Both flows support dry_run; retirement defaults to preview, so set dry_run=false to remove the tag.
Both RC publishing and retirement use npm-release-manual and wait for another member of the Embedded SDK team to approve through the workflow run's Review deployments prompt. The release owner arranges this approval. Before approving, check the selected source commit and version in the run summary, or the expected version for retirement.
Publish to NPM (Manual) is the escape hatch for when the automatic Publish to NPM path doesn't fire even though the release itself is fine — e.g. a release commit's CI run got cancelled by merge-queue churn, or failed on a flaky/unrelated job. It is not a way to bypass a real CI failure.
Run it from main, supplying the exact commit SHA of the chore: release commit to publish. It refuses anything that isn't an already-merged chore: release commit on main, and like automatic publishing, no-ops if that version is already on npm. It shares the npm-release-manual environment with RC publishing/retirement, so it needs the same Embedded SDK team approval through Review deployments — check the commit, message, and version in the run summary before approving.
Stable versions should normally remain available. If one needs to be retired, ask an npm maintainer to deprecate the version and coordinate any GitHub Release cleanup.
If a version accidentally contains secrets or malicious content, ask an npm maintainer to fully unpublish that version, subject to npm's unpublish policy. RC retirement leaves the package downloadable and is insufficient for this case. Rotate any exposed credentials and coordinate GitHub Release cleanup.
Publishing helpers live in .github/scripts/; each helper documents its invocation. Run the publishing helper tests locally with:
bash .github/scripts/publish-helpers.test.shThese tests exercise inline publishing commands and helpers using mocked npm and HTTP responses plus local Git repositories, without publishing or sending notifications. CI also runs input validation, workflow guards, GitHub Release creation tests, and RC retirement tests. Live workflow runs are still needed to verify npm trust, environment approvals, and runner configuration.
Run release readiness tests without installing dependencies:
node --test .github/scripts/release-readiness.test.cjsThe event workflow is read-only; the evaluator runs trusted code from main and reads candidate files through the GitHub API without executing them.
To roll out the approval changes:
- Configure
npm-release-manualto require another Embedded SDK team member's approval and prevent self-approval. All three environments must permit workflows frommainonly; RC and manual-publish sources are selected through workflow inputs. Update the npm trusted publisher configuration for RC publishing, retirement, and manual stable publishing to matchnpm-release-manual, then verify all three workflows' OIDC and approval behavior. - After the readiness workflows land on
main, verify PR push, review approval/dismissal,mainadvancement, and merge queue events. Addrelease-readinessas a required check onmain, selecting GitHub Actions as its source. - Only after the check is verified and required, remove the stable publishing approval from
npm-release. Keep approval required innpm-release-manual.
Inside docs/, use relative links between files (e.g. [link](../getting-started/index.md)), not /docs/... absolute links. Absolute links don't resolve correctly when the same file is served at a versioned URL like /docs/0.47/....
<!-- Good -->
See the [Getting Started guide](../getting-started/index.md).
<!-- Bad — breaks when served at /docs/0.47/... -->
See the [Getting Started guide](/docs/getting-started/index.md).