Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Frontend Tests

on:
pull_request:
branches: [main]
paths:
- frontend/**
- .github/workflows/frontend-tests.yml
push:
branches: [main]
paths:
- frontend/**
- .github/workflows/frontend-tests.yml
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install dependencies
run: |
if [ -f "package-lock.json" ]; then
npm ci
else
npm install
fi

- name: Run Jest tests with coverage
run: npm run test:ci
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Chainvoice is a decentralized invoicing platform that enables secure, transparen
- [Project Structure](#project-structure)
- [Getting Started](#getting-started)
- [Frontend Setup](#frontend-setup)
- [Frontend Testing (Jest)](#frontend-testing-jest)
- [Smart Contract Testing](#smart-contract-testing)
- [Deploy to Ethereum Classic](#deploy-to-ethereum-classic)
- [Environment Variables](#environment-variables)
Expand Down Expand Up @@ -89,6 +90,17 @@ npm run dev
4. **Open application**
Navigate to `http://localhost:5173` in your browser

## Frontend Testing (Jest)

1. **Navigate to frontend directory**
cd frontend

2. **Run tests**
npm test

3. **Run tests with coverage (CI mode)**
npm run test:ci

## Smart Contract Testing

> **Prerequisites:** [Foundry](https://getfoundry.sh/) must be installed
Expand Down
41 changes: 36 additions & 5 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
# React + Vite
# Chainvoice Frontend

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This frontend is built with React and Vite.

Currently, two official plugins are available:
## Prerequisites

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- Node.js 20+
- npm

## Development

1. Install dependencies:
npm install

2. Start local dev server:
npm run dev

3. Build production assets:
npm run build

## Testing

Jest is configured for utility-level unit testing.

- Run tests:
npm test

- Run tests with coverage (CI mode):
npm run test:ci

- Watch mode:
npm run test:watch

## Current Test Scope

- Invoice amount calculations
- Single invoice validation rules
- Batch invoice validation rules
- Negative amount prevention and message consistency
10 changes: 10 additions & 0 deletions frontend/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
rootDir: ".",
testEnvironment: "node",
testMatch: ["<rootDir>/tests/**/*.test.[jt]s?(x)"],
collectCoverageFrom: [
"src/utils/invoiceCalculations.js",
"src/utils/invoiceValidation.js",
],
coverageDirectory: "<rootDir>/coverage",
};
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand",
"test:watch": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --watch",
"test:ci": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --ci --coverage"
},
"dependencies": {
"@emotion/react": "^11.14.0",
Expand Down Expand Up @@ -66,6 +69,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jest": "^30.2.0",
"postcss": "^8.5.0",
"tailwindcss": "^3.4.17",
"vite": "^6.0.5"
Expand Down
59 changes: 50 additions & 9 deletions frontend/src/components/TokenPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,24 @@ const TokenItem = memo(function TokenItem({
query,
isSelected,
onSelect,
disabled = false,
isLoading = false,
}) {
const handleClick = useCallback(() => {
if (disabled) return;
onSelect(token);
}, [onSelect, token]);
}, [disabled, onSelect, token]);

return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={cn(
"w-full flex items-center gap-3 p-3 rounded-lg text-left",
"hover:bg-gray-50 focus:bg-gray-50 focus:outline-none transition-colors duration-200",
"border border-transparent hover:border-gray-200",
disabled && "opacity-60 cursor-not-allowed",
isSelected && "bg-blue-50 border-blue-200 ring-1 ring-blue-200"
)}
>
Expand Down Expand Up @@ -162,9 +167,11 @@ const TokenItem = memo(function TokenItem({
</div>
</div>

{isSelected && (
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600 flex-shrink-0" />
) : isSelected ? (
<div className="w-2 h-2 rounded-full bg-blue-600 flex-shrink-0" />
)}
) : null}
</button>
);
});
Expand All @@ -181,6 +188,8 @@ export function TokenPicker({
onCustomTokenClick,
}) {
const [open, setOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const [selectingTokenAddress, setSelectingTokenAddress] = useState(null);
const inputRef = useRef(null);
const {
tokens,
Expand All @@ -202,12 +211,28 @@ export function TokenPicker({
}
}, [open, setQuery]);

const handleSelect = (token) => {
onSelect(token);
setOpen(false);
const handleSelect = async (token) => {
if (isSelecting) return;

const tokenAddress = token.contract_address || token.address;
setIsSelecting(true);
setSelectingTokenAddress(tokenAddress);

try {
const shouldClose = await Promise.resolve(onSelect(token));
if (shouldClose !== false) {
setOpen(false);
}
} catch {
// Parent handler is responsible for surfacing a user-facing error.
} finally {
setIsSelecting(false);
setSelectingTokenAddress(null);
}
};

const handleCustomTokenClick = () => {
if (isSelecting) return;
if (onCustomTokenClick) {
onCustomTokenClick();
}
Expand All @@ -221,7 +246,7 @@ export function TokenPicker({
<Button
type="button"
variant="outline"
disabled={disabled || isOnTestnet}
disabled={disabled || isOnTestnet || isSelecting}
onClick={() => setOpen(true)}
className={cn(
"h-12 px-4 justify-between bg-white hover:bg-gray-50 border border-gray-300 text-gray-900",
Expand Down Expand Up @@ -271,7 +296,12 @@ export function TokenPicker({
</div>
)}

<Modal isOpen={open} onClose={() => setOpen(false)}>
<Modal
isOpen={open}
onClose={() => {
if (!isSelecting) setOpen(false);
}}
>
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<Coins className="w-5 h-5 text-blue-600" />
Expand All @@ -280,7 +310,10 @@ export function TokenPicker({
</h2>
<button
type="button"
onClick={() => setOpen(false)}
onClick={() => {
if (!isSelecting) setOpen(false);
}}
disabled={isSelecting}
className="ml-auto p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
Expand All @@ -294,6 +327,7 @@ export function TokenPicker({
ref={inputRef}
placeholder={placeholder}
value={query}
disabled={isSelecting}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 pr-10 h-12 border-gray-300 text-black"
/>
Expand Down Expand Up @@ -376,6 +410,12 @@ export function TokenPicker({
key={token.contract_address}
token={token}
query={query}
disabled={isSelecting}
isLoading={
isSelecting &&
selectingTokenAddress ===
(token.contract_address || token.address)
}
isSelected={
selected?.contract_address === token.contract_address ||
selected?.address === token.contract_address
Expand All @@ -392,6 +432,7 @@ export function TokenPicker({
<button
type="button"
onClick={handleCustomTokenClick}
disabled={isSelecting}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors border-2 border-dashed border-gray-200 hover:border-gray-300"
>
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/ui/copyButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const CopyButton = ({ textToCopy, className = "" }) => {

const handleCopy = async (e) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
Expand All @@ -16,11 +17,19 @@ const CopyButton = ({ textToCopy, className = "" }) => {
}
};

const handleKeyDown = (e) => {
if (e.key === "Enter" || e.key === " ") {
handleCopy(e);
}
};

return (
<button
type="button"
<span
role="button"
tabIndex={0}
onClick={handleCopy}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-gray-100 transition-colors ${className}`}
onKeyDown={handleKeyDown}
className={`inline-flex cursor-pointer items-center gap-1 px-2 py-1 text-xs rounded hover:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 ${className}`}
title="Copy address"
>
{copied ? (
Expand All @@ -34,7 +43,7 @@ const CopyButton = ({ textToCopy, className = "" }) => {
<span className="text-gray-500">Copy</span>
</>
)}
</button>
</span>
);
};

Expand Down
Loading
Loading