chore: migrate tx-builder from safe-apps repo to this monorepo (#6953)

* chore: migrate tx-builder from safe-apps repo to this monorepo

create-react-app is deprecated and I’ve migrated the code to vite.
web3 is deprecated and I’ve moved the code to ethersjs
the cypress tests were broken and I dropped them
original code used mui4 -> here we’ve migrated to mui6

* fix(tx-builder): remove build from tx-builder

* fix(tx-builder): vite config

* fix(tx-builder): linting

* fix(tx-builder): fix typo

* fix(tx-builder): don’t mutate array

* fix(tx-builder): fix exponential backtracking

* fix(tx-builder): git workflows

* fix(tx-builder): fix lint setup

* fix(tx-builder): align package dependencies

* fix(tx-builder): invalid react-dom version

* fix(tx-builder): git workflow

* fix(tx-builder): drag and drop was not working

react-beautiful-dnd was no longer supported and it was not compatible with react 19. Moved to @hello-pangea/dnd

* fix(tx-builder): fix pallet issues
This commit is contained in:
Daniel Dimitrov
2026-01-22 12:43:50 +01:00
committed by GitHub
parent 38a025ce6b
commit bde7fc7a95
188 changed files with 21332 additions and 184 deletions
+85
View File
@@ -0,0 +1,85 @@
name: tx-builder Checks
on:
pull_request:
paths:
- apps/tx-builder/**
- packages/**
push:
branches:
- main
- dev
paths:
- apps/tx-builder/**
- packages/**
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
permissions:
checks: write
pull-requests: read
statuses: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Run ESLint
run: yarn workspace @safe-global/tx-builder lint
prettier:
permissions:
checks: write
pull-requests: read
statuses: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Run Prettier check
run: yarn workspace @safe-global/tx-builder prettier
type-check:
permissions:
checks: write
pull-requests: read
statuses: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Type check
run: yarn workspace @safe-global/tx-builder type-check
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Run unit tests
run: yarn workspace @safe-global/tx-builder test --ci --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
flags: tx-builder
fail_ci_if_error: false
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Build
run: yarn workspace @safe-global/tx-builder build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: tx-builder-build
path: apps/tx-builder/build
retention-days: 7
+228
View File
@@ -0,0 +1,228 @@
name: tx-builder Deploy
on:
pull_request:
paths:
- apps/tx-builder/**
- packages/**
- .github/workflows/tx-builder-deploy.yml
push:
branches:
- dev
paths:
- apps/tx-builder/**
- packages/**
workflow_dispatch:
inputs:
release:
description: 'Create production release'
type: boolean
default: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
WORKING_DIRECTORY: apps/tx-builder
jobs:
pr-preview:
name: PR Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
id-token: write
steps:
- name: Post building comment
uses: mshick/add-pr-comment@v2
with:
message-id: tx-builder-preview
message: |
## tx-builder Preview
⏳ Building preview deployment...
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: ./.github/actions/yarn
- name: Build
run: yarn workspace @safe-global/tx-builder build
env:
VITE_TENDERLY_ORG_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_ORG_NAME }}
VITE_TENDERLY_PROJECT_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_PROJECT_NAME }}
VITE_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ secrets.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL }}
- name: Extract branch name
id: extract_branch
shell: bash
run: echo "branch=$(echo $GITHUB_HEAD_REF | sed 's/[^a-z0-9]/_/ig' | sed 's/[A-Z]/\L&/g')" >> $GITHUB_OUTPUT
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: ${{ secrets.AWS_ROLE }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy to S3
env:
BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/tx-builder/${{ steps.extract_branch.outputs.branch }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: aws s3 sync ./build $BUCKET --delete
- name: Post deployment link
if: always()
uses: mshick/add-pr-comment@v2
with:
message-id: tx-builder-preview
message: |
## tx-builder Preview
✅ Deploy successful!
**Preview URL:**
https://${{ steps.extract_branch.outputs.branch }}--tx-builder.review.5afe.dev/tx-builder/
message-failure: |
## tx-builder Preview
❌ Deploy failed!
repo-token: ${{ secrets.GITHUB_TOKEN }}
deploy-staging:
name: Deploy Staging
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/yarn
- name: Build
run: yarn workspace @safe-global/tx-builder build
env:
VITE_TENDERLY_ORG_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_ORG_NAME }}
VITE_TENDERLY_PROJECT_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_PROJECT_NAME }}
VITE_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ secrets.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: ${{ secrets.AWS_ROLE }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy to staging
env:
BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current/tx-builder
working-directory: ${{ env.WORKING_DIRECTORY }}
run: aws s3 sync ./build $BUCKET --delete
production-release:
name: Production Release
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release
permissions:
contents: write
id-token: write
env:
PROD_DEPLOYMENT_HOOK_URL: ${{ secrets.PROD_DEPLOYMENT_HOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/yarn
- name: Extract version
id: version
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
VERSION=$(node -p 'require("./package.json").version')
echo "version=tx-builder-v$VERSION" >> $GITHUB_OUTPUT
echo "version_number=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Version: $VERSION"
- name: Check if tag already exists
run: |
if git rev-parse "${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
echo "❌ Tag ${{ steps.version.outputs.version }} already exists!"
echo "Make sure you've bumped the version in apps/tx-builder/package.json"
exit 1
fi
echo "✅ Tag is new"
- name: Build
run: yarn workspace @safe-global/tx-builder build
env:
VITE_TENDERLY_ORG_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_ORG_NAME }}
VITE_TENDERLY_PROJECT_NAME: ${{ secrets.NEXT_PUBLIC_TENDERLY_PROJECT_NAME }}
VITE_TENDERLY_SIMULATE_ENDPOINT_URL: ${{ secrets.NEXT_PUBLIC_TENDERLY_SIMULATE_ENDPOINT_URL }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: ${{ secrets.AWS_ROLE }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy to production S3
env:
BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ steps.version.outputs.version }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: aws s3 sync ./build $BUCKET --delete
- name: Create git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag ${{ steps.version.outputs.version }}
git push origin ${{ steps.version.outputs.version }}
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
name: tx-builder ${{ steps.version.outputs.version_number }}
tag_name: ${{ steps.version.outputs.version }}
generate_release_notes: true
body: |
## tx-builder v${{ steps.version.outputs.version_number }}
Release of tx-builder Safe App.
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Trigger production deployment
if: env.PROD_DEPLOYMENT_HOOK_URL != ''
env:
PROD_DEPLOYMENT_HOOK_TOKEN: ${{ secrets.PROD_DEPLOYMENT_HOOK_TOKEN }}
VERSION_TAG: ${{ steps.version.outputs.version }}
run: |
HTTP_STATUS=$(curl --silent --show-error --output /dev/null --write-out "%{http_code}" -X POST \
-F token="$PROD_DEPLOYMENT_HOOK_TOKEN" \
-F ref=master \
-F "variables[TRIGGER_RELEASE_COMMIT_TAG]=$VERSION_TAG" \
$PROD_DEPLOYMENT_HOOK_URL)
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
echo "❌ Production deployment trigger failed (HTTP $HTTP_STATUS)"
exit 1
fi
- name: Summary
run: |
echo "### 🎉 tx-builder Release Complete!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The release has been:" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Built and uploaded to S3" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Tagged in git" >> $GITHUB_STEP_SUMMARY
echo "- ✅ GitHub release created" >> $GITHUB_STEP_SUMMARY
+4
View File
@@ -103,3 +103,7 @@ apps/web/src/types/contracts/*
# expo-plugins/notifications-service-ios
/expo-plugins/notification-service-ios/dist/*
# tx-builder
apps/tx-builder/build/*
+125 -147
View File
@@ -1,195 +1,173 @@
<!--
SYNC IMPACT REPORT
==================
Version Change: N/A → 1.0.0 (Initial constitution)
Modified Principles: N/A (Initial creation)
Added Sections:
- Core Principles (5 principles established)
- Architecture Constraints
- Quality Standards
- Governance
Removed Sections: N/A (Initial creation)
Templates Status:
✅ plan-template.md - Constitution Check section aligns with principles
✅ spec-template.md - Scope/requirements align with testing and quality principles
✅ tasks-template.md - Task categorization reflects principle-driven task types
⚠ Command files - Generic guidance ready, no CLAUDE-specific references
Follow-up TODOs:
- RATIFICATION_DATE needs to be set (currently marked TODO)
- Future reviews should validate compliance with monorepo-specific constraints
Sync Impact Report
==================
Version change: 1.0.0 (initial)
Modified principles: N/A (initial constitution)
Added sections:
- I. Type Safety (NON-NEGOTIABLE)
- II. Branch Protection & Quality Gates
- III. Cross-Platform Consistency
- IV. Testing Discipline
- V. Feature Organization
- VI. Theme System Integrity
- Technology Stack section
- Development Workflow section
Templates requiring updates:
- .specify/templates/plan-template.md ✅ (Constitution Check section compatible)
- .specify/templates/spec-template.md ✅ (no changes required)
- .specify/templates/tasks-template.md ✅ (no changes required)
Follow-up TODOs: None
-->
# Safe{Wallet} Constitution
# Safe{Wallet} Monorepo Constitution
## Core Principles
### I. Monorepo Unity
### I. Type Safety (NON-NEGOTIABLE)
The Safe{Wallet} monorepo MUST maintain unified standards and shared infrastructure across web and mobile platforms.
TypeScript's type system is the first line of defense against bugs. The `any` type is
strictly forbidden across the entire codebase.
**Rules:**
- **MUST** never use `any` type - create proper interfaces/types instead
- **MUST** use `as` type assertions only when TypeScript cannot infer correctly, never to bypass type errors
- **MUST** use Zod for runtime validation at system boundaries (API responses, user input)
- **MUST** prefer interfaces over type aliases for object shapes
- Test helpers MUST be properly typed - no `as any` escapes in tests
- Shared packages (`packages/**`) MUST work for both web and mobile environments
- Environment variables MUST follow dual-prefix patterns (`NEXT_PUBLIC_*` || `EXPO_PUBLIC_*`) in shared code
- Theme tokens MUST be defined in `@safe-global/theme` as the single source of truth
- Breaking changes to shared packages MUST be validated against both platforms before merging
- Redux store state changes MUST be compatible with both web and mobile consumers
**Rationale**: A single `any` can cascade through the codebase, silently breaking type guarantees
and allowing runtime errors that TypeScript was designed to prevent.
**Rationale:** The monorepo architecture enables code reuse and consistency. Violations create platform-specific bugs, deployment blockers, and maintenance overhead that compound over time.
### II. Branch Protection & Quality Gates
### II. Type Safety
All changes flow through pull requests. Direct pushes to protected branches are forbidden.
TypeScript's type system MUST be strictly enforced without exceptions.
- **MUST** never push directly to `dev` (default) or `main` (production) branches
- **MUST** create feature branches for all changes: `feature/your-feature-name`
- **MUST** pass all quality gates before committing:
- `yarn workspace @safe-global/web type-check` (or mobile)
- `yarn workspace @safe-global/web lint`
- `yarn workspace @safe-global/web prettier`
- `yarn workspace @safe-global/web test`
- **MUST** use semantic commit messages: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`
- **MUST** never commit failing code - all tests must pass
**Rules:**
**Rationale**: Pre-commit hooks enforce these gates, but understanding them prevents wasted cycles
debugging hook failures.
- The `any` type is PROHIBITED in all code (production and tests)
- Type assertions (`as any`) are PROHIBITED - create properly typed helpers instead
- All functions, variables, and component props MUST have explicit types
- Type-check MUST pass before committing: `yarn workspace @safe-global/{web|mobile} type-check`
- Generated type files (e.g., `packages/utils/src/types/contracts/`) MUST NOT be manually edited
### III. Cross-Platform Consistency
**Rationale:** Type safety prevents runtime errors, enables refactoring confidence, and provides documentation. The `any` escape hatch creates hidden bugs that surface in production. Properly typed code is self-documenting and maintainable.
The monorepo serves both web (Next.js) and mobile (Expo/React Native). Changes to shared code
MUST work for both platforms.
### III. Test-First Development
- **MUST** test shared package changes (`packages/**`) against both web and mobile
- **MUST** use dual environment variable patterns in shared packages:
`process.env.NEXT_PUBLIC_* || process.env.EXPO_PUBLIC_*`
- **MUST** ensure Redux store changes work for both platforms
- **MUST** never import platform-specific code into shared packages
- Web-only features go in `apps/web/`, mobile-only in `apps/mobile/`
All new logic, services, and hooks MUST be covered by unit tests.
**Rationale**: Breaking mobile when changing web (or vice versa) creates silent regressions that
surface late in the development cycle.
**Rules:**
### IV. Testing Discipline
- Business logic MUST have unit tests before merging
- Tests MUST verify behavior, not implementation details (avoid checking specific Redux actions dispatched)
- Network requests MUST be mocked using Mock Service Worker (MSW), not by mocking `fetch` or ethers.js
- Test data MUST be generated using faker for consistency and coverage
- Redux tests MUST verify resulting state changes using properly typed helpers
- Shared package tests MUST validate behavior for both web and mobile environments
- E2E smoke tests (web) MUST pass in CI before deployment
Tests validate behavior, not implementation details. Network mocking uses MSW, not function mocks.
**Rationale:** Tests prevent regressions, document intended behavior, and enable confident refactoring. Implementation-detail tests break during legitimate refactors and provide false confidence.
- **MUST** use Mock Service Worker (MSW) for network request mocking, not `jest.mock(fetch)`
- **MUST** use MSW for blockchain RPC call mocking, not ethers.js mocks
- **MUST** use faker for test data generation
- **MUST** colocate test files with source: `Component.tsx``Component.test.tsx`
- **MUST** verify Redux state changes, not action dispatch calls
- **MUST** cover new logic, services, and hooks with unit tests
### IV. Design System Compliance
**Rationale**: Mocking implementation details (function calls) creates brittle tests that break
on refactoring. Mocking boundaries (network) validates actual behavior.
UI implementation MUST follow established design system patterns and theme tokens.
### V. Feature Organization
**Rules:**
Features are modular, isolated, and controlled by feature flags.
- **Web:** MUST use MUI components with Safe MUI theme; MUST use theme variables from `vars.css`
- **Mobile:** MUST use Tamagui components; MUST use theme tokens from `@safe-global/theme`
- Hard-coded colors, spacing, or typography values are PROHIBITED
- `apps/web/src/styles/vars.css` MUST NOT be manually edited (auto-generated)
- New components MUST have corresponding Storybook stories (`.stories.tsx`) for web
- Component stories MUST document all important states and variations
- Light and dark mode theming MUST be updated together for consistency
- **MUST** create new web features in `src/features/[feature-name]/`
- **MUST** gate new web features behind feature flags (CGW API chains config)
- **MUST** create Storybook stories for new web components (`.stories.tsx`)
- Only truly global code belongs in top-level `src/` folders
- **MUST** handle loading, error, and empty states in all UI components
**Rationale:** Consistent design system implementation ensures brand consistency, accessibility, and maintainability. Theme tokens enable global styling changes without code modifications. Storybook provides visual documentation and isolated component development.
**Rationale**: Feature flags enable incremental rollout and quick rollback. Feature folders
prevent cross-feature coupling and make code ownership clear.
### V. Safe-Specific Security
### VI. Theme System Integrity
Safe{Wallet} handles multi-signature transactions and must enforce security best practices.
The `@safe-global/theme` package is the single source of truth for all design tokens.
**Rules:**
- **MUST** never hardcode colors, spacing, or typography values
- **MUST** never edit `apps/web/src/styles/vars.css` directly - it's auto-generated
- **MUST** use theme tokens: MUI theme (web) or Tamagui tokens (mobile)
- **MUST** update both light and dark mode palettes together for consistency
- **MUST** run `yarn workspace @safe-global/web css-vars` after theme changes
- Private keys or sensitive data MUST NEVER be hardcoded - use environment variables
- Ethereum addresses MUST be validated using `ethers.js` utilities (e.g., `isAddress`)
- Safe addresses MUST always be referenced with their `chainId` (Safes are chain-specific)
- Transaction building MUST use Safe SDK patterns (`@safe-global/protocol-kit`, `@safe-global/api-kit`)
- Wallet provider integration MUST follow established Web3-Onboard patterns
- Security-critical changes MUST be reviewed by security-knowledgeable maintainers
- Multi-signature threshold and owner validation MUST be respected in all transaction flows
**Rationale**: Hardcoded values create visual inconsistencies and make theme updates impossible
to apply uniformly.
**Rationale:** Safe{Wallet} is a smart contract wallet managing user assets. Security vulnerabilities can lead to loss of funds. These patterns are established best practices from the Safe ecosystem and MUST be preserved.
## Technology Stack
## Architecture Constraints
The monorepo enforces specific technology choices to maintain consistency:
### Code Organization
| Layer | Web (apps/web) | Mobile (apps/mobile) | Shared (packages/) |
| --------------- | -------------------- | -------------------- | ------------------ |
| Framework | Next.js | Expo (React Native) | Platform-agnostic |
| UI Library | MUI | Tamagui | N/A |
| State | Redux + RTK Query | Redux + RTK Query | Redux slices |
| Styling | CSS vars + MUI theme | Tamagui tokens | @safe-global/theme |
| Testing | Jest + MSW + Cypress | Jest + MSW | Jest + MSW |
| Package Manager | Yarn 4 workspaces | Yarn 4 workspaces | Yarn 4 workspaces |
**Web Application:**
**Web3/Blockchain stack**:
- New features MUST be created in `src/features/` with dedicated folders
- Only globally-used components, hooks, and services belong in top-level `src/` folders
- Each new feature MUST be behind a feature flag (managed via CGW API chain configs)
- Safe SDK: `@safe-global/protocol-kit`, `@safe-global/api-kit`
- Wallet connection: Web3-Onboard
- Ethereum: ethers.js
- Address validation: Always use `isAddress` from ethers.js
- Chain awareness: Always include `chainId` when referencing a Safe
**Shared Packages:**
## Development Workflow
- Cross-platform logic MUST reside in `packages/` directory
- Shared code MUST NOT import platform-specific dependencies directly
- Package changes MUST be validated against both web and mobile consumers
Every code change follows this sequence:
### Dependency Management
1. **Branch**: Create feature branch from `dev`
2. **Implement**: Write code following platform-specific code style guides
- Web: `apps/web/docs/code-style.md`
- Mobile: `apps/mobile/docs/code-style.md`
3. **Validate**: Run quality gates (type-check → lint → prettier → test)
4. **Commit**: Use semantic commit messages, ensure hooks pass
5. **PR**: Fill out PR template, ensure CI passes
6. **Review**: Address feedback, maintain all quality gates
- Yarn 4 workspaces MUST be used for all dependency management
- Dependencies MUST be added via `yarn workspace <workspace-name> add <package>`
- Monorepo-wide dependencies belong in root `package.json`
- Breaking dependency updates MUST be validated across all affected workspaces
**Generated files** - MUST NOT be manually edited:
### Workflow Enforcement
- Pre-commit hooks (Husky) MUST run lint-staged (prettier) and type-check
- Pre-push hooks MUST run linting before pushing
- All checks (type-check, lint, prettier, tests) MUST pass before committing
- Commit messages MUST follow semantic commit conventions (feat:, fix:, refactor:, etc.)
## Quality Standards
### Code Quality
**Principles:**
- Follow DRY (Don't Repeat Yourself) - extract reusable functions, hooks, and components
- Prefer functional programming - use pure functions, avoid side effects
- Use declarative patterns - React hooks, derived state over manual synchronization
- Keep implementations simple - avoid over-engineering and premature abstraction
**Error Handling:**
- All UI components MUST handle loading, error, and empty states
- Network errors MUST be properly caught and communicated to users
- Chain-specific logic MUST handle multi-chain scenarios appropriately
### Performance
- Yarn commands MUST be scoped to specific workspaces when possible (e.g., `yarn workspace @safe-global/web dev`)
- Build artifacts (`.next`, `node_modules/.cache`) MUST be cleared when troubleshooting build issues
- Images and assets MUST be optimized before committing
### Documentation
- Storybook stories MUST document component props and usage patterns (web)
- Complex business logic MUST include explanatory comments where non-obvious
- Code style guidelines MUST be followed:
- Web: `apps/web/docs/code-style.md`
- Mobile: `apps/mobile/docs/code-style.md`
- `packages/utils/src/types/contracts/` - auto-generated from ABIs
- `apps/web/src/styles/vars.css` - auto-generated from theme
## Governance
### Amendment Process
This constitution supersedes informal practices. All PRs and code reviews MUST verify compliance
with these principles.
This constitution supersedes all other development practices and guidelines.
**Amendment process**:
**Amendment Requirements:**
1. Propose changes via PR to this file
2. Justify why the change is necessary
3. Update version according to semantic versioning:
- MAJOR: Principle removal or fundamental redefinition
- MINOR: New principle or materially expanded guidance
- PATCH: Clarifications and typo fixes
4. Document migration plan if breaking change
1. Proposed changes MUST be documented with rationale
2. Proposed changes MUST include impact analysis on existing principles
3. Amendments MUST be reviewed and approved by repository maintainers
4. Version MUST be incremented following semantic versioning:
- **MAJOR**: Backward-incompatible governance changes or principle removals
- **MINOR**: New principles or materially expanded guidance
- **PATCH**: Clarifications, wording improvements, non-semantic refinements
5. Dependent templates (plan, spec, tasks, commands) MUST be updated for consistency
6. Migration plan MUST be provided for breaking changes
**Compliance verification**:
### Compliance Review
- Pre-commit hooks enforce type-check, lint, and formatting
- CI enforces full test suite
- Code review MUST verify principle adherence
- Feature flag requirement verified at PR review
- All PRs MUST verify compliance with constitutional principles
- Complexity that violates principles MUST be explicitly justified in plan documentation
- Pre-commit and pre-push hooks enforce automated compliance for type-checking, linting, and formatting
- Constitution violations discovered post-merge MUST be addressed with high priority
### Runtime Guidance
- AI contributors MUST reference `AGENTS.md` for comprehensive development guidelines
- `CLAUDE.md` provides quick-reference instructions for critical workflows
- Constitution provides non-negotiable principles; guidance files provide implementation patterns
**Version**: 1.0.0 | **Ratified**: TODO(RATIFICATION_DATE) | **Last Amended**: 2026-01-08
**Version**: 1.0.0 | **Ratified**: 2026-01-12 | **Last Amended**: 2026-01-12
+120
View File
@@ -0,0 +1,120 @@
# Transaction Builder
A Safe App that allows users to compose custom contract interactions and batch them into a single transaction.
## Development
### Prerequisites
- Node.js 20+
- Yarn 4 (via corepack)
### Setup
```bash
# From monorepo root
yarn install
# Start development server
yarn workspace @safe-global/tx-builder dev
```
The app will be available at `http://localhost:3000/tx-builder/`
### Commands
| Command | Description |
| --------------------------------------------------- | ---------------- |
| `yarn workspace @safe-global/tx-builder dev` | Start dev server |
| `yarn workspace @safe-global/tx-builder build` | Production build |
| `yarn workspace @safe-global/tx-builder test` | Run unit tests |
| `yarn workspace @safe-global/tx-builder lint` | Run ESLint |
| `yarn workspace @safe-global/tx-builder type-check` | TypeScript check |
## Testing
### Unit Tests
Unit tests use Jest with React Testing Library:
```bash
yarn workspace @safe-global/tx-builder test
```
### E2E Tests
E2E testing for Safe Apps is handled by the main web app's Cypress suite at `apps/web/cypress/e2e/safe-apps/`. This ensures tests run against the actual Safe{Wallet} integration rather than mocked iframe environments.
## Architecture
### Key Directories
- `src/components/` - React components
- `src/pages/` - Route page components
- `src/hooks/` - Custom React hooks
- `src/store/` - React Context providers
- `src/lib/` - Business logic (batches, simulation, storage)
- `src/theme/` - MUI theme configuration
### Tech Stack
- **React 19** with TypeScript
- **MUI v6** for UI components
- **ethers.js v6** for Ethereum interactions
- **Vite** for bundling
- **Jest** + React Testing Library for unit tests
### Safe Apps Integration
This app runs as a Safe App inside Safe{Wallet}'s iframe. It uses:
- `@safe-global/safe-apps-sdk` for communication with Safe{Wallet}
- `@safe-global/safe-apps-react-sdk` for React hooks
- `@safe-global/safe-apps-provider` for ethers.js provider
## Environment Variables
| Variable | Description | Default |
| ------------------------------------- | --------------------- | ------- |
| `VITE_TENDERLY_SIMULATE_ENDPOINT_URL` | Tenderly API endpoint | - |
| `VITE_TENDERLY_PROJECT_NAME` | Tenderly project name | - |
| `VITE_TENDERLY_ORG_NAME` | Tenderly organization | - |
## Deployment
The app is automatically deployed via GitHub Actions (`.github/workflows/tx-builder-deploy.yml`):
### Environments
| Environment | Trigger | URL Pattern |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| PR Preview | Pull request | `https://{branch}--tx-builder.review.5afe.dev/tx-builder/` |
| Staging | Push to `dev` | Staging environment |
| Production | Manual workflow dispatch | Versioned release |
### Production Release Process
1. **Bump version**: Update the version in `package.json` and merge to `main`:
```bash
# Edit apps/tx-builder/package.json
# Change "version": "1.0.0" to "version": "1.1.0"
git commit -m "chore: bump tx-builder to 1.1.0"
```
2. **Trigger release**: Go to [GitHub Actions](../../actions/workflows/tx-builder-deploy.yml) and click "Run workflow" (from `main` or `dev` branch)
3. **Automated steps**: The workflow will:
- Build the production bundle from `main`
- Upload to S3 releases bucket
- Create a git tag (`tx-builder-vX.X.X`)
- Create a GitHub release
4. **Contact DevOps**: Send the git tag link to DevOps for production deployment:
```
https://github.com/safe-global/safe-wallet-monorepo/releases/tag/tx-builder-vX.X.X
```
## License
See [LICENSE](../../LICENSE) in the monorepo root.
+95
View File
@@ -0,0 +1,95 @@
import js from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'
import nextVitals from 'eslint-config-next/core-web-vitals'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
const typeScriptRules = {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unnecessary-type-constraint': 'warn',
}
const withTypeScriptRules = (configs) => {
let hasTypeScriptPlugin = false
const updatedConfigs = configs.map((config) => {
if (!config.plugins || !config.plugins['@typescript-eslint']) {
return config
}
hasTypeScriptPlugin = true
return {
...config,
languageOptions: {
...config.languageOptions,
parser: tsParser,
ecmaVersion: config.languageOptions?.ecmaVersion ?? 2020,
sourceType: config.languageOptions?.sourceType ?? 'module',
parserOptions: {
...config.languageOptions?.parserOptions,
},
},
rules: {
...config.rules,
...typeScriptRules,
},
}
})
if (hasTypeScriptPlugin) {
return updatedConfigs
}
return [
...updatedConfigs,
{
plugins: {
'@typescript-eslint': typescriptEslint,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
},
rules: typeScriptRules,
},
]
}
export default [
{
ignores: ['**/node_modules/', '**/build/', '**/vite.config.ts', '**/jest.config.cjs', '**/src/__mocks__/**'],
},
...withTypeScriptRules(nextVitals),
{
languageOptions: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
project: ['./tsconfig.json'],
},
},
rules: {
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/use-memo': 'off',
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
},
},
]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/tx-builder/tx-builder.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transaction Builder - Safe App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
/** @type {import('jest').Config} */
const config = {
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/src/__mocks__/fileMock.js',
'^../../utils/env$': '<rootDir>/src/utils/__mocks__/env.ts',
},
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
useESM: true,
tsconfig: {
module: 'ESNext',
moduleResolution: 'bundler',
allowImportingTsExtensions: false,
noEmit: false,
},
},
],
},
transformIgnorePatterns: ['node_modules/(?!(@safe-global)/)'],
testMatch: ['**/*.test.ts', '**/*.test.tsx'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/main.tsx', '!src/vite-env.d.ts'],
}
module.exports = config
+72
View File
@@ -0,0 +1,72 @@
{
"name": "@safe-global/tx-builder",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 4000",
"build": "tsc -b && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prettier": "prettier --check src",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^6.5.0",
"@mui/lab": "^6.0.0-beta.20",
"@mui/material": "^6.5.0",
"@safe-global/safe-apps-provider": "^0.18.5",
"@safe-global/safe-apps-react-sdk": "^4.7.2",
"@safe-global/safe-apps-sdk": "^9.1.0",
"@safe-global/safe-deployments": "^1.37.49",
"@safe-global/safe-gateway-typescript-sdk": "^3.22.9",
"axios": "^1.8.2",
"ethereum-blockies-base64": "^1.0.2",
"ethers": "^6.14.3",
"evm-proxy-detection": "^1.0.0",
"localforage": "^1.10.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.54.2",
"react-is": "^19.1.0",
"react-media": "^1.10.0",
"react-router-dom": "^6.28.2",
"react-virtuoso": "^4.12.3",
"styled-components": "^5.3.11",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0",
"@hookform/devtools": "^4.3.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.7",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/styled-components": "^5.1.34",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.29.0",
"eslint-config-next": "^16.0.5",
"eslint-config-prettier": "^9.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"msw": "^2.7.3",
"prettier": "^3.6.2",
"ts-jest": "^29.2.5",
"typescript": "~5.9.2",
"vite": "^6.0.7",
"vite-plugin-svgr": "^4.3.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Transaction Builder",
"description": "A Safe app to compose custom transactions",
"iconPath": "tx-builder.png",
"icons": [
{
"src": "tx-builder.png",
"sizes": "256x256",
"type": "image/png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+47
View File
@@ -0,0 +1,47 @@
import { Routes, Route } from 'react-router-dom'
import Header from './components/Header'
import CreateTransactions from './pages/CreateTransactions'
import Dashboard from './pages/Dashboard'
import EditTransactionLibrary from './pages/EditTransactionLibrary'
import ReviewAndConfirm from './pages/ReviewAndConfirm'
import SaveTransactionLibrary from './pages/SaveTransactionLibrary'
import TransactionLibrary from './pages/TransactionLibrary'
import {
HOME_PATH,
EDIT_BATCH_PATH,
REVIEW_AND_CONFIRM_PATH,
SAVE_BATCH_PATH,
TRANSACTION_LIBRARY_PATH,
} from './routes/routes'
const App = () => {
return (
<>
{/* App Header */}
<Header />
<Routes>
{/* Dashboard Screen (Create transactions) */}
<Route path={HOME_PATH} element={<Dashboard />}>
{/* Transactions Batch section */}
<Route index element={<CreateTransactions />} />
{/* Save Batch section */}
<Route path={SAVE_BATCH_PATH} element={<SaveTransactionLibrary />} />
{/* Edit Batch section */}
<Route path={EDIT_BATCH_PATH} element={<EditTransactionLibrary />} />
</Route>
{/* Review & Confirm Screen */}
<Route path={REVIEW_AND_CONFIRM_PATH} element={<ReviewAndConfirm />} />
{/* Transaction Library Screen */}
<Route path={TRANSACTION_LIBRARY_PATH} element={<TransactionLibrary />} />
</Routes>
</>
)
}
export default App
@@ -0,0 +1 @@
module.exports = 'test-file-stub'
@@ -0,0 +1,3 @@
<svg width="176" height="117" viewBox="0 0 176 117" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M175.154 110.769C175.42 110.694 175.574 110.418 175.499 110.152C175.424 109.887 175.148 109.732 174.882 109.807L175.154 110.769ZM2.60834 24.5178C2.35502 24.4079 2.06055 24.5241 1.95061 24.7774L0.159115 28.9054C0.0491812 29.1587 0.165414 29.4532 0.41873 29.5632C0.672045 29.6731 0.966519 29.5569 1.07645 29.3035L2.66889 25.6342L6.33825 27.2266C6.59156 27.3366 6.88603 27.2203 6.99597 26.967C7.1059 26.7137 6.98967 26.4192 6.73635 26.3093L2.60834 24.5178ZM1.9442 25.16C2.42519 26.3786 2.9426 27.5793 3.49454 28.761L4.40059 28.3379C3.85714 27.1743 3.34779 25.9923 2.87436 24.7929L1.9442 25.16ZM7.22466 35.6489C8.60309 37.873 10.1067 40.0127 11.7198 42.0601L12.5053 41.4412C10.9148 39.4225 9.43284 37.3136 8.07466 35.1221L7.22466 35.6489ZM16.8909 47.9366C18.7225 49.8056 20.6498 51.5732 22.6569 53.2315L23.2938 52.4607C21.3133 50.8243 19.4119 49.0803 17.6052 47.2367L16.8909 47.9366ZM28.9422 57.8978C31.1268 59.3502 33.3776 60.6819 35.6781 61.8847L36.1415 60.9985C33.8721 59.812 31.6515 58.4981 29.4958 57.065L28.9422 57.8978ZM42.7995 65.132C45.2517 66.0918 47.7392 66.9065 50.2441 67.5667L50.499 66.5997C48.0323 65.9496 45.5813 65.147 43.164 64.2008L42.7995 65.132ZM57.9319 69.0938C60.5476 69.4426 63.1627 69.6153 65.757 69.6014L65.7517 68.6014C63.2052 68.6151 60.6361 68.4456 58.0641 68.1026L57.9319 69.0938ZM73.5776 68.9706C76.1694 68.5573 78.718 67.9373 81.2015 67.0994L80.8819 66.1519C78.4532 66.9713 75.959 67.5783 73.4201 67.9831L73.5776 68.9706ZM88.3929 63.9441C90.6547 62.7009 92.834 61.2428 94.9108 59.5602L94.2813 58.7832C92.251 60.4281 90.1212 61.853 87.9112 63.0678L88.3929 63.9441ZM100.574 54.105C102.241 52.206 103.811 50.1203 105.268 47.8414L104.426 47.3026C102.995 49.5402 101.455 51.5853 99.8222 53.4452L100.574 54.105ZM109.036 40.9716C109.582 39.8035 110.104 38.5965 110.601 37.3501L109.672 36.98C109.182 38.2087 108.668 39.3977 108.13 40.5478L109.036 40.9716ZM110.601 37.3501C111.118 36.053 111.547 34.7639 111.893 33.4873L110.928 33.2253C110.592 34.4647 110.175 35.7177 109.672 36.98L110.601 37.3501ZM113.034 25.4567C113.068 22.6226 112.704 19.9157 112.009 17.3963L111.045 17.6621C111.715 20.0909 112.067 22.7043 112.034 25.4448L113.034 25.4567ZM108.695 9.97258C107.161 7.6079 105.284 5.58035 103.17 3.98556L102.568 4.78385C104.584 6.30495 106.383 8.24505 107.857 10.5168L108.695 9.97258ZM95.7967 0.492533C93.1642 -0.110069 90.3985 -0.115301 87.631 0.5873L87.877 1.55656C90.4861 0.89419 93.0909 0.899001 95.5736 1.46732L95.7967 0.492533ZM80.3331 4.22133C78.3745 5.72715 76.4838 7.66388 74.7098 10.0701L75.5147 10.6635C77.2395 8.32401 79.0661 6.45682 80.9426 5.01412L80.3331 4.22133ZM70.5648 17.0471C70.0023 18.2147 69.4603 19.4489 68.9412 20.7518L69.8702 21.1219C70.3811 19.8396 70.9138 18.6268 71.4657 17.4811L70.5648 17.0471ZM68.9412 20.7518C68.4453 21.9966 67.9866 23.2775 67.5662 24.5903L68.5185 24.8952C68.9321 23.6037 69.3831 22.3445 69.8702 21.1219L68.9412 20.7518ZM65.5748 32.4828C65.0789 35.0992 64.7204 37.7964 64.5061 40.5474L65.5031 40.625C65.7146 37.9096 66.0684 35.2487 66.5573 32.669L65.5748 32.4828ZM64.2967 48.6809C64.3655 51.3678 64.5706 54.0804 64.9182 56.7957L65.9102 56.6687C65.5669 53.9874 65.3643 51.3087 65.2964 48.6553L64.2967 48.6809ZM66.366 64.8045C66.984 67.4332 67.7432 70.0433 68.6493 72.6135L69.5924 72.281C68.6986 69.7458 67.9495 67.1703 67.3395 64.5756L66.366 64.8045ZM71.7864 80.1255C72.9708 82.5563 74.3033 84.9281 75.7894 87.2205L76.6285 86.6765C75.1653 84.4194 73.8526 82.083 72.6854 79.6874L71.7864 80.1255ZM80.6434 93.7559C82.3824 95.806 84.2707 97.7624 86.3132 99.6066L86.9834 98.8644C84.9745 97.0506 83.117 95.1261 81.406 93.109L80.6434 93.7559ZM92.7127 104.65C94.9062 106.153 97.2382 107.542 99.7125 108.802L100.166 107.91C97.7304 106.67 95.4357 105.304 93.278 103.825L92.7127 104.65ZM107.184 112.04C109.666 112.942 112.266 113.727 114.987 114.384L115.222 113.412C112.536 112.763 109.971 111.989 107.526 111.1L107.184 112.04ZM122.956 115.884C125.555 116.243 128.251 116.495 131.045 116.632L131.094 115.633C128.328 115.497 125.662 115.248 123.093 114.893L122.956 115.884ZM139.171 116.709C141.795 116.634 144.497 116.465 147.277 116.197L147.181 115.202C144.422 115.468 141.743 115.635 139.143 115.709L139.171 116.709ZM155.324 115.173C157.916 114.767 160.572 114.279 163.291 113.705L163.084 112.727C160.382 113.297 157.743 113.782 155.169 114.186L155.324 115.173ZM171.172 111.849C172.486 111.508 173.813 111.149 175.154 110.769L174.882 109.807C173.548 110.184 172.227 110.542 170.921 110.881L171.172 111.849Z" fill="#A1A3A7"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,13 @@
<svg width="156" height="113" viewBox="0 0 156 113" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.0844 -0.000976562C27.1504 -0.000976562 23.0837 4.08492 23.0837 9.04228V90.4316C23.0837 95.389 27.1504 99.4749 32.0844 99.4749H146.093C151.027 99.4749 155.094 95.389 155.094 90.4316V9.04228C155.094 4.08492 151.027 -0.000976562 146.093 -0.000976562H32.0844ZM31.8558 5.8507H59.4007C59.5056 5.88636 59.6162 5.92202 59.7269 5.94578C61.5563 6.23105 63.071 7.18192 64.6672 8.84587C66.2635 10.5098 67.8365 12.8277 69.5609 15.3118C70.8775 17.2076 72.3165 19.2222 74.0351 21.0645H28.8737V8.89334C28.8737 7.1521 30.1495 5.85054 31.8565 5.85054L31.8558 5.8507ZM70.5611 5.85054H146.304C148.021 5.85054 149.304 7.15205 149.304 8.89334V21.0645H87.0132C86.8257 21.0288 86.6382 21.017 86.4507 21.017C81.4355 20.4643 78.4827 16.6251 75.108 11.7935C73.7194 9.80853 72.2078 7.77017 70.5616 5.85062L70.5611 5.85054ZM36.4006 10.5318C34.8004 10.5318 33.5056 11.8404 33.5056 13.4575C33.5056 15.0747 34.8004 16.3833 36.4006 16.3833C38.0007 16.3833 39.2955 15.0747 39.2955 13.4575C39.2955 11.8404 38.0007 10.5318 36.4006 10.5318ZM46.8224 10.5318C45.2222 10.5318 43.9275 11.8404 43.9275 13.4575C43.9275 15.0747 45.2223 16.3833 46.8224 16.3833C48.4226 16.3833 49.7174 15.0747 49.7174 13.4575C49.7174 11.8404 48.4226 10.5318 46.8224 10.5318ZM57.2443 10.5318C55.6441 10.5318 54.3493 11.8404 54.3493 13.4575C54.3493 15.0747 55.6442 16.3833 57.2443 16.3833C58.8444 16.3833 60.1393 15.0747 60.1393 13.4575C60.1393 11.8404 58.8444 10.5318 57.2443 10.5318ZM28.8737 26.916H148.146V90.5912C148.146 92.3264 146.871 93.6234 145.164 93.6234H31.8555C30.1491 93.6234 28.8737 92.3264 28.8737 90.5912V26.916Z" fill="#E2E3E3"/>
<ellipse cx="32.5885" cy="82.3735" rx="31.6825" ry="30.6264" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.5068 60.8462C81.5068 60.3856 81.8807 60.0117 82.3413 60.0117L96.1115 60.0117C96.5722 60.0117 96.946 60.3856 96.946 60.8462C96.946 61.3069 96.5722 61.6808 96.1115 61.6808L82.3413 61.6808C81.8807 61.6808 81.5068 61.3069 81.5068 60.8462Z" fill="#B2B5B2"/>
<rect x="73.665" y="54.2549" width="31.1222" height="13.1798" rx="6" stroke="#B2B5B2" stroke-width="4" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.9624 79.2203C98.9624 78.7596 99.3363 78.3857 99.7969 78.3857L113.567 78.3857C114.028 78.3857 114.402 78.7596 114.402 79.2203C114.402 79.6809 114.028 80.0548 113.567 80.0548L99.7969 80.0548C99.3363 80.0548 98.9624 79.6809 98.9624 79.2203Z" fill="#B2B5B2"/>
<rect x="91.1206" y="72.6289" width="31.1222" height="13.1798" rx="6" stroke="#B2B5B2" stroke-width="4" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.8884 41.5523C65.8884 41.0916 66.2623 40.7178 66.7229 40.7178L80.4931 40.7178C80.9538 40.7178 81.3276 41.0916 81.3276 41.5523C81.3276 42.013 80.9538 42.3868 80.4931 42.3868L66.7229 42.3868C66.2623 42.3868 65.8884 42.013 65.8884 41.5523Z" fill="#B2B5B2"/>
<rect x="58.0466" y="34.9609" width="31.1222" height="13.1798" rx="6" stroke="#B2B5B2" stroke-width="4" stroke-linejoin="round"/>
<path d="M62.3045 66.8489C61.2484 66.4265 60.1923 66.9545 59.7699 68.0106L57.2353 74.8751C53.6446 64.4199 43.6118 57.0273 32.3117 57.0273C21.1173 57.133 11.0845 64.2087 7.38818 74.7695C6.96575 75.8256 7.5994 77.0929 8.65548 77.5153C9.71156 77.9378 10.9789 77.3041 11.4013 76.248C14.5695 67.377 22.9126 61.4629 32.3117 61.4629C41.8165 61.4629 50.1595 67.5882 53.2222 76.248L47.0969 74.0303C46.0408 73.6078 44.9847 74.1359 44.5623 75.192C44.3511 75.72 44.4567 76.248 44.6679 76.6705C44.8791 77.0929 45.3015 77.5153 45.724 77.7266L56.8128 81.8453C57.3409 82.0565 57.8689 81.9509 58.2914 81.7397C58.7138 81.5285 59.1362 81.106 59.3474 80.6836L63.4662 69.3835C63.783 68.3274 63.2549 67.2713 62.3045 66.8489Z" fill="#008C73"/>
<path d="M56.3906 87.5483C55.2289 87.2315 54.0672 87.9707 53.856 89.0268C51.3214 98.8484 42.4503 105.607 32.4175 105.607C22.2791 105.607 13.3024 98.5316 10.8734 88.8156L17.4211 91.2446C18.4772 91.667 19.5333 91.139 19.9557 90.0829C20.1669 89.5549 20.0613 89.0268 19.8501 88.6044C19.6389 88.182 19.2165 87.7595 18.794 87.5483L7.70515 83.4296C7.17711 83.2184 6.64907 83.324 6.22664 83.5352C5.80421 83.7464 5.38177 84.1688 5.17056 84.5913L1.05183 95.6801C0.629401 96.7362 1.15744 97.7923 2.21353 98.2147C3.26961 98.6372 4.32569 98.1091 4.74812 97.053L7.07151 90.8222C10.3454 101.911 20.695 109.937 32.4175 109.937C44.4568 109.937 55.0177 101.805 57.9747 90.1885C58.1859 89.0268 57.4466 87.8651 56.3906 87.5483Z" fill="#008C73"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.6519 83.5357C23.6519 88.2881 27.4538 92.09 32.2061 92.09C36.9585 92.09 40.7604 88.2881 40.7604 83.5357C40.7604 78.7833 36.9585 74.9814 32.2061 74.9814C27.4538 74.9814 23.6519 78.7833 23.6519 83.5357ZM27.8763 83.4305C27.8763 81.1072 29.7772 79.1006 32.2062 79.1006C34.5296 79.1006 36.5361 81.1072 36.5361 83.4305C36.5361 85.7539 34.6352 87.7605 32.2062 87.7605C29.8828 87.7605 27.8763 85.8595 27.8763 83.4305Z" fill="#008C73"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,82 @@
import { ReactElement } from 'react'
import AccordionMUI, { AccordionProps as AccordionMUIProps } from '@mui/material/Accordion'
import AccordionSummaryMUI, { AccordionSummaryProps as AccordionSummaryMUIProps } from '@mui/material/AccordionSummary'
import styled from 'styled-components'
import FixedIcon from '../FixedIcon'
type AccordionProps = AccordionMUIProps & {
compact?: boolean
}
type StyledAccordionProps = AccordionMUIProps & {
$compact?: AccordionProps['compact']
}
const StyledAccordion = styled(AccordionMUI)<StyledAccordionProps>`
&.MuiAccordion-root {
border-radius: ${({ $compact }) => ($compact ? '8px' : '0')};
border: ${({ $compact, theme }) => ($compact ? '2px solid ' + theme.palette.divider : 'none')};
border-bottom: 2px solid ${({ theme }) => theme.palette.divider};
margin-bottom: ${({ $compact }) => ($compact ? '16px' : '0')};
overflow: hidden;
&:before {
height: 0;
}
&:first-child {
border-top: 2px solid ${({ theme }) => theme.palette.divider};
}
&.Mui-expanded {
margin: ${({ $compact }) => ($compact ? '0 0 16px 0' : '0')};
}
.MuiAccordionDetails-root {
padding: 16px;
}
}
`
const StyledAccordionSummary = styled(AccordionSummaryMUI)`
&.MuiAccordionSummary-root {
&.Mui-expanded {
min-height: 48px;
border-bottom: 2px solid ${({ theme }) => theme.palette.divider};
background-color: ${({ theme }) => theme.palette.background.default};
}
&:hover {
background-color: ${({ theme }) => theme.palette.background.default};
}
.MuiAccordionSummary-content {
&.Mui-expanded {
margin: 0;
}
}
.MuiIconButton-root {
font-size: 0;
padding: 16px;
}
}
`
export const Accordion = ({ compact, children, ...props }: AccordionProps): ReactElement => {
return (
<StyledAccordion square elevation={0} $compact={compact} {...props}>
{children}
</StyledAccordion>
)
}
export const AccordionSummary = ({ children, ...props }: AccordionSummaryMUIProps): ReactElement => {
return (
<StyledAccordionSummary expandIcon={<FixedIcon type="chevronDown" />} {...props}>
{children}
</StyledAccordionSummary>
)
}
export { default as AccordionActions } from '@mui/material/AccordionActions'
export { default as AccordionDetails } from '@mui/material/AccordionDetails'
+204
View File
@@ -0,0 +1,204 @@
import React, { ReactElement, ReactNode, HTMLAttributes } from 'react'
import ButtonMUI, { ButtonProps as ButtonMUIProps } from '@mui/material/Button'
import { alpha } from '@mui/material/styles'
import styled, { css, DefaultTheme, FlattenInterpolation, ThemeProps } from 'styled-components'
import { Icon, IconProps } from './Icon'
type Colors = 'primary' | 'secondary' | 'error'
type Variations = 'bordered' | 'contained' | 'outlined'
type CustomButtonMuiProps = Omit<ButtonMUIProps, 'size' | 'color' | 'variant'> & {
to?: string
component?: ReactNode
}
type LocalProps = {
children?: ReactNode
color?: Colors
variant?: Variations
iconType?: IconProps['type']
iconSize?: IconProps['size']
}
type Props = LocalProps & CustomButtonMuiProps & HTMLAttributes<HTMLButtonElement>
const StyledIcon = styled(Icon)<IconProps>`
margin-right: 5px;
`
const customStyles: {
[key in Colors]: {
[key in Variations]: FlattenInterpolation<ThemeProps<DefaultTheme>>
}
} = {
primary: {
contained: css`
color: ${({ theme }) => theme.palette.background.main};
background-color: ${({ theme }) => theme.palette.primary.main};
box-shadow: 1px 2px 10px ${alpha('#28363D', 0.18)};
&:hover {
color: ${({ theme }) => theme.palette.background.main};
background-color: ${({ theme }) => theme.palette.primary.dark};
}
`,
outlined: css`
color: ${({ theme }) => theme.palette.primary.main};
background-color: transparent;
path.icon-color {
fill: ${({ theme }) => theme.palette.primary.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.primary.main};
}
&:hover {
color: ${({ theme }) => theme.palette.primary.dark};
}
`,
bordered: css`
color: ${({ theme }) => theme.palette.primary.main};
background-color: transparent;
border: 2px solid ${({ theme }) => theme.palette.primary.main};
path.icon-color {
fill: ${({ theme }) => theme.palette.primary.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.primary.main};
}
&:hover {
background: ${({ theme }) => theme.palette.background.light};
}
`,
},
secondary: {
contained: css`
color: ${({ theme }) => theme.palette.primary.main};
background-color: ${({ theme }) => theme.palette.secondary.main};
box-shadow: 1px 2px 10px ${alpha('#28363D', 0.18)};
path.icon-color {
fill: ${({ theme }) => theme.palette.primary.main};
}
&:hover {
path.icon-color {
fill: ${({ theme }) => theme.palette.primary.main};
}
background-color: ${({ theme }) => theme.palette.secondary.dark};
}
`,
outlined: css`
color: ${({ theme }) => theme.palette.secondary.main};
background-color: transparent;
path.icon-color {
fill: ${({ theme }) => theme.palette.secondary.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.secondary.main};
}
`,
bordered: css`
color: ${({ theme }) => theme.palette.secondary.main};
background-color: transparent;
border: 2px solid ${({ theme }) => theme.palette.secondary.main};
path.icon-color {
fill: ${({ theme }) => theme.palette.secondary.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.secondary.main};
}
`,
},
error: {
contained: css`
color: ${({ theme }) => theme.palette.error.main};
background-color: ${({ theme }) => theme.palette.error.background};
&:hover {
background-color: ${({ theme }) => theme.palette.error.light};
color: ${({ theme }) => theme.palette.error.dark};
}
`,
outlined: css`
color: ${({ theme }) => theme.palette.error.main};
background-color: transparent;
path.icon-color {
fill: ${({ theme }) => theme.palette.error.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.error.main};
}
`,
bordered: css`
color: ${({ theme }) => theme.palette.error.main};
background-color: transparent;
border: 2px solid ${({ theme }) => theme.palette.error.main};
path.icon-color {
fill: ${({ theme }) => theme.palette.error.main};
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.error.main};
}
`,
},
}
const StyledButton = styled(ButtonMUI)<{ $localProps: LocalProps }>`
&& {
font-weight: 700;
padding: 8px 1.4rem;
min-width: 120px;
&.MuiButton-root {
text-transform: none;
border-radius: 8px;
letter-spacing: 0;
}
&.Mui-disabled {
color: ${({ theme }) => theme.palette.background.main};
}
path.icon-color {
fill: ${({ theme }) => theme.palette.background.main};
}
&:disabled {
opacity: 0.5;
}
${({ $localProps }) => {
if ($localProps.color !== undefined && $localProps.variant !== undefined) {
return customStyles[$localProps.color][$localProps.variant]
}
}}
}
`
const Button = ({
children,
color = 'primary',
variant = 'contained',
iconType,
iconSize,
// We need destructuring all LocalProps, remaining props are for CustomButtonMuiProps
...buttonMuiProps
}: Props): ReactElement => {
return (
<StyledButton className={`${color} ${variant}`} {...buttonMuiProps} $localProps={{ color, variant }}>
{iconType && iconSize && <StyledIcon size={iconSize} type={iconType} />}
{children}
</StyledButton>
)
}
export default Button
@@ -0,0 +1,36 @@
import React from 'react'
import styled from 'styled-components'
import { alpha } from '@mui/material/styles'
const StyledCard = styled.div`
box-shadow: 1px 2px 10px 0 ${alpha('#28363D', 0.18)};
border-radius: 8px;
padding: 24px;
background-color: ${({ theme }) => theme.palette.common.white};
position: relative;
`
const Disabled = styled.div`
opacity: 0.5;
position: absolute;
height: 100%;
width: 100%;
background-color: ${({ theme }) => theme.palette.common.white};
z-index: 1;
top: 0;
left: 0;
`
type Props = {
className?: string
disabled?: boolean
} & React.HTMLAttributes<HTMLDivElement>
const Card: React.FC<Props> = ({ className, children, disabled, ...rest }): React.ReactElement => (
<StyledCard className={className} {...rest}>
{disabled && <Disabled />}
{children}
</StyledCard>
)
export default Card
@@ -0,0 +1,31 @@
import React from 'react'
import MuiAlert from '@mui/lab/Alert'
import MuiAlertTitle from '@mui/lab/AlertTitle'
import styled from 'styled-components'
import { useTransactionLibrary } from '../store'
const ChecksumWarning = () => {
const { hasChecksumWarning, setHasChecksumWarning } = useTransactionLibrary()
if (!hasChecksumWarning) {
return null
}
return (
<ChecksumWrapper>
<MuiAlert severity="warning" onClose={() => setHasChecksumWarning(false)}>
<MuiAlertTitle>This batch contains some changed properties since you saved or downloaded it</MuiAlertTitle>
</MuiAlert>
</ChecksumWrapper>
)
}
const ChecksumWrapper = styled.div`
position: fixed;
width: 100%;
z-index: 10;
background-color: transparent;
height: 70px;
`
export default ChecksumWarning
@@ -0,0 +1,157 @@
import { useContext, useRef } from 'react'
import { alpha } from '@mui/material'
import Hidden from '@mui/material/Hidden'
import styled from 'styled-components'
import { useTheme } from '@mui/material/styles'
import { ReactComponent as CreateNewBatchLightSvg } from '../assets/new-batch-light.svg'
import { ReactComponent as CreateNewBatchDarkSvg } from '../assets/new-batch-dark.svg'
import { ReactComponent as ArrowBlock } from '../assets/arrowtotheblock.svg'
import useDropZone from '../hooks/useDropZone'
import { useMediaQuery } from '@mui/material'
import { Icon } from './Icon'
import Text from './Text'
import ButtonLink from './buttons/ButtonLink'
import { EModes, ThemeModeContext } from '../theme/SafeThemeProvider'
type CreateNewBatchCardProps = {
onFileSelected: (file: File | null) => void
}
const CreateNewBatchCard = ({ onFileSelected }: CreateNewBatchCardProps) => {
const theme = useTheme()
const mode = useContext(ThemeModeContext)
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'))
const fileRef = useRef<HTMLInputElement | null>(null)
const { isOverDropZone, isAcceptError, dropHandlers } = useDropZone((file: File | null) => {
onFileSelected(file)
}, '.json')
const handleFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault()
if (event.target.files?.length) {
onFileSelected(event.target.files[0])
event.target.value = ''
}
}
const handleBrowse = function (event: React.MouseEvent) {
event.preventDefault()
fileRef.current?.click()
}
return (
<Wrapper isSmallScreen={isSmallScreen}>
<Hidden smDown>
{mode === EModes.DARK ? <CreateNewBatchDarkSvg /> : <CreateNewBatchLightSvg />}
<StyledArrowBlock />
</Hidden>
<StyledCreateBatchContent>
<StyledText variant="body1">Start creating a new batch </StyledText>
<StyledText variant="body1">or</StyledText>
<StyledDragAndDropFileContainer
{...dropHandlers}
dragOver={isOverDropZone}
fullWidth={isSmallScreen}
error={isAcceptError}
>
{isAcceptError ? (
<StyledText variant="body1" error={isAcceptError}>
The uploaded file is not a valid JSON file
</StyledText>
) : (
<>
<Icon type="termsOfUse" size="sm" />
<StyledText variant="body1">Drag and drop a JSON file or</StyledText>
<StyledButtonLink color="secondary" onClick={handleBrowse}>
choose a file
</StyledButtonLink>
</>
)}
</StyledDragAndDropFileContainer>
<input ref={fileRef} id="logo-input" type="file" onChange={handleFileSelected} accept=".json" hidden />
</StyledCreateBatchContent>
</Wrapper>
)
}
export default CreateNewBatchCard
const Wrapper = styled.div<{ isSmallScreen: boolean }>`
text-align: center;
position: relative;
margin-top: ${({ isSmallScreen }) => (isSmallScreen ? '0' : '64px')};
`
const StyledArrowBlock = styled(ArrowBlock)`
position: absolute;
left: -2px;
top: 7rem;
`
const StyledCreateBatchContent = styled.div`
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
`
const StyledDragAndDropFileContainer = styled.div<{
dragOver: boolean
fullWidth: boolean
error: boolean
}>`
box-sizing: border-box;
max-width: ${({ fullWidth }) => (fullWidth ? '100%' : '430px')};
width: 100%;
border: 2px dashed ${({ theme, error }) => (error ? theme.palette.error.main : theme.palette.secondary.dark)};
border-radius: 8px;
background-color: ${({ theme, error }) =>
error ? alpha(theme.palette.error.main, 0.7) : theme.palette.secondary.background};
padding: 24px;
margin: 6px auto;
display: flex;
justify-content: center;
align-items: center;
svg {
margin-right: 4px;
}
${({ dragOver, error, theme }) => {
if (dragOver) {
return `
transition: all 0.2s ease-in-out;
transform: scale(1.05);
`
}
return `
border-color: ${error ? theme.palette.error.main : theme.palette.secondary.dark};
background-color: ${error ? alpha(theme.palette.error.main, 0.7) : theme.palette.secondary.background};
`
}}
`
const StyledText = styled(Text)<{ error?: boolean }>`
&& {
color: ${({ error, theme }) => (error ? theme.palette.common.white : theme.palette.text.secondary)};
}
`
const StyledButtonLink = styled(ButtonLink)`
margin-left: 0.3rem;
padding: 0;
&& > p {
color: ${({ theme }) => theme.palette.upload.primary};
text-decoration: underline;
&:hover {
color: ${({ theme }) => theme.palette.backdrop.main};
}
}
`
@@ -0,0 +1,29 @@
import React from 'react'
import styled from 'styled-components'
type Props = {
className?: string
orientation?: 'vertical' | 'horizontal'
}
const HorizontalDivider = styled.div`
margin: 16px -1.6rem;
border-top: solid 1px ${({ theme }) => theme.palette.border.light};
width: calc(100% + 3.2rem);
`
const VerticalDivider = styled.div`
border-right: 1px solid ${({ theme }) => theme.legacy.colors.separator};
margin: 0 5px;
height: 100%;
`
const Divider = ({ className, orientation }: Props): React.ReactElement => {
return orientation === 'vertical' ? (
<VerticalDivider className={className} />
) : (
<HorizontalDivider className={className} />
)
}
export default Divider
@@ -0,0 +1,23 @@
import React from 'react'
import styled from 'styled-components'
import { type Theme } from '@mui/material/styles'
type Props = {
className?: string
color: keyof Theme['palette']
children?: React.ReactNode
}
const StyledDot = styled.div<Props>`
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
height: 36px;
width: 36px;
background-color: ${({ theme, color }) => theme.palette[color].main};
`
const Dot: React.FC<Props> = ({ children, ...rest }): React.ReactElement => <StyledDot {...rest}>{children}</StyledDot>
export default Dot
@@ -0,0 +1,149 @@
import React, { useState } from 'react'
import styled from 'styled-components'
import { textShortener } from '../utils/strings'
import Text from './Text'
import { Theme } from '@mui/material'
import ExplorerButton from './buttons/ExplorerButton'
import Identicon, { identiconSizes } from './buttons/Identicon'
import CopyToClipboardBtn from './buttons/CopyToClipboardBtn'
import EllipsisMenu from './EllipsisMenu'
export type ExplorerInfo = () => { url: string; alt: string }
type SizeType = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export interface EllipsisMenuItem {
label: string
onClick: () => void
disabled?: boolean
}
const StyledContainer = styled.div`
display: flex;
align-items: center;
`
const AvatarContainer = styled.div`
display: flex;
margin-right: 8px;
`
const InfoContainer = styled.div`
display: flex;
align-items: flex-start;
justify-content: center;
flex-direction: column;
`
const AddressContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
`
const StyledImg = styled.img<{ size: SizeType }>`
height: ${({ size }) => identiconSizes[size]};
width: ${({ size }) => identiconSizes[size]};
`
type Props = {
className?: string
hash: string
showHash?: boolean
shortenHash?: number
name?: string
strongName?: boolean
textColor?: keyof Theme['palette']
textSize?: SizeType
showAvatar?: boolean
customAvatar?: string
customAvatarFallback?: string
avatarSize?: SizeType
showCopyBtn?: boolean
menuItems?: EllipsisMenuItem[]
explorerUrl?: ExplorerInfo
}
type ShortNameProps =
| {
shouldShowShortName: boolean
shouldCopyShortName?: boolean
shortName: string
}
| {
shouldShowShortName?: boolean
shouldCopyShortName: boolean
shortName: string
}
| {
shouldShowShortName?: never
shouldCopyShortName?: never
shortName?: string
}
type EthHashInfoProps = Props & ShortNameProps
const EthHashInfo = ({
hash,
showHash = true,
name,
className,
shortenHash,
showAvatar,
customAvatar,
customAvatarFallback,
avatarSize = 'md',
showCopyBtn,
menuItems,
explorerUrl,
shortName,
shouldShowShortName,
shouldCopyShortName,
}: EthHashInfoProps): React.ReactElement => {
const [fallbackToIdenticon, setFallbackToIdenticon] = useState(false)
const [fallbackSrc, setFallabckSrc] = useState<undefined | string>(undefined)
const setAppImageFallback = (): void => {
if (customAvatarFallback && !fallbackToIdenticon) {
setFallabckSrc(customAvatarFallback)
} else {
setFallbackToIdenticon(true)
}
}
return (
<StyledContainer className={className}>
{showAvatar && (
<AvatarContainer>
{!fallbackToIdenticon && customAvatar ? (
<StyledImg src={fallbackSrc || customAvatar} size={avatarSize} onError={setAppImageFallback} />
) : (
<Identicon address={hash} size={avatarSize} />
)}
</AvatarContainer>
)}
<InfoContainer>
{name && <Text>{name}</Text>}
<AddressContainer>
{showHash && (
<Text>
{shouldShowShortName && (
<Text component="span" strong>
{shortName}:
</Text>
)}
{shortenHash ? textShortener(hash, shortenHash + 2, shortenHash) : hash}
</Text>
)}
{showCopyBtn && <CopyToClipboardBtn textToCopy={shouldCopyShortName ? `${shortName}:${hash}` : hash} />}
{explorerUrl && <ExplorerButton explorerUrl={explorerUrl} />}
{menuItems && <EllipsisMenu menuItems={menuItems} />}
</AddressContainer>
</InfoContainer>
</StyledContainer>
)
}
export default EthHashInfo
@@ -0,0 +1,47 @@
import styled from 'styled-components'
export type EditableLabelProps = {
children: React.ReactNode
onEdit: (value: string) => void
}
const EditableLabel = ({ children, onEdit }: EditableLabelProps) => {
return (
<EditableComponent
contentEditable="true"
suppressContentEditableWarning={true}
onBlur={(event) => onEdit(event.target.innerText)}
onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter') {
;(event.target as HTMLDivElement).blur()
event.preventDefault()
}
}}
onClick={(event) => event.stopPropagation()}
>
{children}
</EditableComponent>
)
}
export default EditableLabel
const EditableComponent = styled.div`
font-family: Averta, 'Roboto', sans-serif;
display: block;
white-space: nowrap;
overflow: hidden;
padding: 10px;
cursor: text;
border-radius: 8px;
border: 1px solid transparent;
&:hover {
border-color: #e2e3e3;
}
&:focus {
outline-color: #008c73;
}
`
@@ -0,0 +1,96 @@
import { ClickAwayListener } from '@mui/material'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import React from 'react'
import styled from 'styled-components'
import FixedIcon from '../FixedIcon'
const StyledMenu = styled(Menu)`
&& {
.MuiMenu-paper {
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
}
.MuiMenu-list {
div:not(:first-child) {
border-top: 1px solid ${({ theme }) => theme.palette.divider};
}
}
}
`
const MenuWrapper = styled.div`
display: flex;
`
const MenuItemWrapper = styled.div`
:focus {
outline-color: ${({ theme }) => theme.palette.divider};
}
`
const IconWrapper = styled.button`
background: none;
border: none;
cursor: pointer;
margin: 0;
border-radius: 50%;
transition: background-color 0.2s ease-in-out;
outline-color: transparent;
height: 24px;
width: 24px;
span {
display: flex;
}
:hover {
background-color: ${({ theme }) => theme.palette.divider};
}
`
export type EllipsisMenuItem = {
label: string
disabled?: boolean
onClick: () => void
}
type Props = {
menuItems: EllipsisMenuItem[]
}
const EllipsisMenu = ({ menuItems }: Props): React.ReactElement => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null)
const handleClick = (event: React.MouseEvent<HTMLElement>): void => setAnchorEl(event.currentTarget)
const closeMenuHandler = () => {
setAnchorEl(null)
}
const onMenuItemClick = (item: EllipsisMenuItem) => {
item.onClick()
closeMenuHandler()
}
return (
<ClickAwayListener onClickAway={closeMenuHandler}>
<MenuWrapper>
<IconWrapper onClick={handleClick}>
<FixedIcon type="options" />
</IconWrapper>
<StyledMenu anchorEl={anchorEl} keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}>
{menuItems.map((item) => (
<MenuItemWrapper key={item.label}>
<MenuItem disabled={item.disabled} onClick={() => onMenuItemClick(item)}>
{item.label}
</MenuItem>
</MenuItemWrapper>
))}
</StyledMenu>
</MenuWrapper>
</ClickAwayListener>
)
}
export default EllipsisMenu
@@ -0,0 +1,31 @@
import React from 'react'
import MuiAlert from '@mui/lab/Alert'
import MuiAlertTitle from '@mui/lab/AlertTitle'
import styled from 'styled-components'
import { useTransactionLibrary } from '../store'
const ErrorAlert = () => {
const { errorMessage, setErrorMessage } = useTransactionLibrary()
if (!errorMessage) {
return null
}
return (
<ErrorAlertContainer>
<MuiAlert severity="error" onClose={() => setErrorMessage('')}>
<MuiAlertTitle>{errorMessage}</MuiAlertTitle>
</MuiAlert>
</ErrorAlertContainer>
)
}
const ErrorAlertContainer = styled.div`
position: fixed;
width: 100%;
z-index: 10;
background-color: transparent;
height: 70px;
`
export default ErrorAlert
@@ -0,0 +1,12 @@
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<path
fill="#008C73"
fillRule="evenodd"
d="M3.431 7.99h2.6c.554 0 1.004.45 1.004 1.005C7.035 9.55 6.585 10 6.03 10H1.005c-.277 0-.529-.112-.71-.294C.111 9.524 0 9.272 0 8.995V3.97c0-.555.45-1.005 1.005-1.005.555 0 1.005.45 1.005 1.005v2.599L8.284.294c.393-.392 1.03-.392 1.422 0 .392.393.392 1.03 0 1.422L3.43 7.99z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<path
fill="#FFFFFF"
fillRule="evenodd"
d="M3.431 7.99h2.6c.554 0 1.004.45 1.004 1.005C7.035 9.55 6.585 10 6.03 10H1.005c-.277 0-.529-.112-.71-.294C.111 9.524 0 9.272 0 8.995V3.97c0-.555.45-1.005 1.005-1.005.555 0 1.005.45 1.005 1.005v2.599L8.284.294c.393-.392 1.03-.392 1.422 0 .392.393.392 1.03 0 1.422L3.43 7.99z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<path
fill="#F02525"
fillRule="evenodd"
d="M6.569 2.01h-2.6c-.554 0-1.004-.45-1.004-1.005C2.965.45 3.415 0 3.97 0h5.025c.277 0 .529.112.71.294.183.182.295.434.295.711V6.03c0 .555-.45 1.005-1.005 1.005-.555 0-1.005-.45-1.005-1.005V3.431L1.716 9.706c-.393.392-1.03.392-1.422 0-.392-.393-.392-1.03 0-1.422L6.57 2.01z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
<path
fill="#FFFFFF"
fillRule="evenodd"
d="M6.569 2.01h-2.6c-.554 0-1.004-.45-1.004-1.005C2.965.45 3.415 0 3.97 0h5.025c.277 0 .529.112.71.294.183.182.295.434.295.711V6.03c0 .555-.45 1.005-1.005 1.005-.555 0-1.005-.45-1.005-1.005V3.431L1.716 9.706c-.393.392-1.03.392-1.422 0-.392-.393-.392-1.03 0-1.422L6.57 2.01z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path
fill="#5D6D74"
fillRule="evenodd"
d="M4.984 4.608l1.302-1.322c.384-.384 1.024-.384 1.408 0 .384.384.384 1.024 0 1.408L4.728 7.702C4.537 7.893 4.28 8 4.024 8c-.256 0-.511-.107-.704-.298L.29 4.694c-.426-.427-.383-1.152.13-1.515.404-.298.98-.213 1.322.15l1.13 1.108.129.022V.982C3 .427 3.447 0 3.98 0c.577 0 1.004.448 1.004.982v3.626z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="6" viewBox="0 0 6 6">
<path
fill="#008C73"
fillRule="evenodd"
className="icon-color"
d="M3 0C1.347 0 0 1.347 0 3s1.347 3 3 3 3-1.347 3-3-1.347-3-3-3z"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7">
<path
fill="#B2B5B2"
fillRule="evenodd"
className="icon-color"
d="M6.709 6.709c-.195.195-.452.292-.71.29-.257.002-.514-.095-.71-.29l-.046-.05L.292 1.706c-.39-.39-.39-1.025 0-1.414.389-.39 1.025-.39 1.414 0l4.293 4.294L10.293.292c.389-.39 1.025-.39 1.414 0 .389.389.389 1.025 0 1.414L6.763 6.649l-.054.06z"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 7 12">
<path
fill="#B2B5B2"
className="icon-color"
fillRule="evenodd"
d="M.291 5.29l.06-.054L5.294.292c.389-.39 1.025-.39 1.414 0 .389.389.389 1.025 0 1.414L2.414 6l4.294 4.293c.389.389.389 1.025 0 1.414s-1.025.389-1.414 0L.341 6.756l-.05-.047c-.195-.195-.292-.452-.291-.71-.001-.257.096-.514.291-.71z"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 7 12">
<path
fill="#B2B5B2"
className="icon-color"
fillRule="evenodd"
d="M6.709 5.29l-.06-.054L1.706.292c-.39-.39-1.025-.39-1.414 0-.39.389-.39 1.025 0 1.414L4.586 6 .292 10.293c-.39.389-.39 1.025 0 1.414.389.389 1.025.389 1.414 0l4.953-4.951.05-.047c.195-.195.292-.452.29-.71.002-.257-.095-.514-.29-.71"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7">
<path
fill="#B2B5B2"
fillRule="evenodd"
className="icon-color"
d="M6.709.291l.054.06 4.944 4.943c.389.389.389 1.025 0 1.414-.39.389-1.025.389-1.414 0L5.999 2.414 1.706 6.708c-.39.389-1.025.389-1.414 0-.39-.389-.39-1.025 0-1.414L5.242.341l.048-.05c.195-.195.452-.292.709-.291.258-.001.515.096.71.291z"
/>
</svg>
)
export default icon
@@ -0,0 +1,13 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 9 9">
<path
fill="#E8673C"
d="M4.5 9C6.985 9 9 6.985 9 4.5S6.985 0 4.5 0 0 2.015 0 4.5 2.015 9 4.5 9zm0-2C3.12 7 2 5.88 2 4.5S3.12 2 4.5 2 7 3.12 7 4.5 5.88 7 4.5 7z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,13 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 9 9">
<path
fill="#008C73"
d="M4.5 9C6.985 9 9 6.985 9 4.5S6.985 0 4.5 0 0 2.015 0 4.5 2.015 9 4.5 9zm0-2C3.12 7 2 5.88 2 4.5S3.12 2 4.5 2 7 3.12 7 4.5 5.88 7 4.5 7z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,17 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
<g fill="none" fillRule="evenodd">
<circle cx="17" cy="17" r="17" fill="#008C73" />
<path
fill="#FFF"
fillRule="nonzero"
className="icon-color"
d="M17 27c5.523 0 10-4.477 10-10S22.523 7 17 7c-5.462 0-9.911 4.382-9.999 9.836-.009.553.432 1.007.984 1.016.552.01 1.007-.431 1.016-.984C9.071 12.506 12.631 9 17 9c4.418 0 8 3.582 8 8s-3.582 8-8 8c-.552 0-1 .448-1 1s.448 1 1 1z"
/>
</g>
</svg>
)
export default icon
@@ -0,0 +1,13 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="5" viewBox="0 0 9 5">
<path
fill="#5D6D74"
d="M3.858 4.754c.355.328.93.328 1.284 0l3.59-3.32C9.305.906 8.899 0 8.09 0H.91C.101 0-.305.907.268 1.436l3.59 3.319z"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,18 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" viewBox="0 0 90 90">
<g fill="none" fillRule="evenodd">
<circle cx="45" cy="45" r="45" fill="#E8E7E6" />
<path
fill="#B2B5B2"
fillRule="nonzero"
d="M26.24 62c-.57 0-1.13-.15-1.623-.437a3.254 3.254 0 0 1-1.18-4.44l18.76-32.501a3.234 3.234 0 0 1 5.606 0l18.761 32.501c.286.495.436 1.057.436 1.628A3.244 3.244 0 0 1 63.76 62H26.24z"
/>
<path fill="#E8E7E6" fillRule="nonzero" d="M47 49h-4V36h4zM47 55h-4v-4h4z" />
<circle cx="78" cy="78" r="11" fill="#FFC05F" fillRule="nonzero" stroke="#F6F7F8" strokeWidth="4" />
</g>
</svg>
)
export default icon
@@ -0,0 +1,18 @@
import React from 'react'
const icon = (
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<circle id="ca7p6cg2da" cx="20" cy="20" r="20" />
</defs>
<g fill="none" fillRule="evenodd">
<circle cx="20" cy="20" r="20" transform="matrix(1, 0, 0, 1, 0, 0)" fill="#E8E7E6" />
<path
fill="#B2B5B2"
d="M 18.02 29.05 L 22.894 29.05 C 23.384 29.05 23.783 28.652 23.783 28.161 C 23.783 28.113 23.779 28.066 23.771 28.018 L 22.519 20.313 C 24.103 19.553 25.203 17.958 25.203 16.104 C 25.203 13.524 23.079 11.429 20.458 11.429 C 17.84 11.429 15.714 13.524 15.714 16.104 C 15.711 17.96 16.81 19.554 18.397 20.314 L 17.143 28.018 C 17.064 28.503 17.393 28.959 17.878 29.038 C 17.925 29.046 17.973 29.05 18.021 29.05 Z"
/>
</g>
</svg>
)
export default icon
@@ -0,0 +1,34 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="96" viewBox="0 0 108 96">
<g fill="none" fillRule="evenodd">
<path d="M0 0H108V96H0z" opacity=".557" />
<g>
<path
fill="#F6F7F8"
d="M49 0c25.405 0 46 20.595 46 46S74.405 92 49 92 3 71.405 3 46 23.595 0 49 0"
transform="translate(5 4)"
/>
<path
fill="#F02525"
d="M42.12 36.726c0-3.675 2.99-6.665 6.665-6.665s6.665 2.99 6.665 6.665-2.99 6.664-6.665 6.664-6.664-2.989-6.664-6.664m26.26 20.668c-3.41-6.652-8.073-10.436-14.173-11.5 3.132-1.86 5.242-5.269 5.242-9.168 0-5.88-4.784-10.665-10.665-10.665-5.88 0-10.664 4.784-10.664 10.665 0 4.317 2.584 8.035 6.282 9.711-9.101 2.803-12.981 11.934-15.353 20.066-.31 1.06.3 2.17 1.36 2.48.187.055.375.081.56.081.867 0 1.665-.567 1.92-1.442 3.69-12.652 8.945-18.05 17.57-18.053 6.424.001 10.987 3.067 14.362 9.65.502.983 1.706 1.372 2.692.866.983-.504 1.37-1.709.867-2.69"
transform="translate(5 4)"
/>
<path
fill="#B2B5B2"
d="M21.496 37.329c-3.675 0-6.665-2.99-6.665-6.665S17.82 24 21.496 24s6.664 2.989 6.664 6.664-2.99 6.665-6.664 6.665m14.07 15.289c.872-1.088 1.829-2.099 2.908-2.984-3.065-5.177-7.032-8.329-12.003-9.544 3.379-1.791 5.689-5.342 5.689-9.426C32.16 24.784 27.376 20 21.496 20c-5.881 0-10.665 4.784-10.665 10.664 0 4.077 2.302 7.623 5.672 9.418C6.632 42.477 2.543 51.998.08 60.442c-.31 1.06.3 2.17 1.36 2.48.187.054.375.081.56.081.867 0 1.665-.568 1.92-1.442 3.69-12.652 8.945-18.052 17.57-18.054 6.228.001 10.729 2.906 14.075 9.111M76.083 37.329c-3.675 0-6.664-2.99-6.664-6.665S72.408 24 76.083 24s6.664 2.989 6.664 6.664-2.99 6.665-6.664 6.665M97.49 60.442c-2.462-8.443-6.55-17.962-16.417-20.359 3.37-1.794 5.674-5.341 5.674-9.419 0-5.88-4.784-10.664-10.664-10.664s-10.664 4.784-10.664 10.664c0 4.078 2.303 7.625 5.674 9.419-4.448 1.082-8.075 3.714-10.981 7.955 1.135.665 2.203 1.464 3.205 2.404 3.18-4.716 7.27-6.934 12.763-6.935 8.625.002 13.88 5.402 17.57 18.054.255.874 1.053 1.442 1.919 1.442.186 0 .374-.027.56-.081 1.06-.31 1.67-1.42 1.36-2.48"
transform="translate(5 4)"
/>
<path
fill="#F02525"
fillRule="nonzero"
d="M76.5 61C83.956 61 90 67.044 90 74.5S83.956 88 76.5 88 63 81.956 63 74.5 69.044 61 76.5 61zm8.026 8.415l-13.111 13.11C72.885 83.46 74.629 84 76.5 84c5.247 0 9.5-4.253 9.5-9.5 0-1.87-.54-3.615-1.474-5.085zM76.5 65c-5.247 0-9.5 4.253-9.5 9.5 0 1.929.575 3.723 1.562 5.221l13.159-13.159C80.223 65.575 78.429 65 76.5 65z"
transform="translate(5 4)"
/>
</g>
</g>
</svg>
)
export default icon
@@ -0,0 +1,13 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="4" viewBox="0 0 16 4">
<g fill="#B2B5B2" className="icon-color" fillRule="evenodd">
<circle cx="2" cy="2" r="2" />
<circle cx="8" cy="2" r="2" />
<circle cx="14" cy="2" r="2" />
</g>
</svg>
)
export default icon
@@ -0,0 +1,20 @@
const icon = (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.7895 4C12.3708 4 12.8421 4.47128 12.8421 5.05263L12.8421 18.9474C12.8421 19.5287 12.3708 20 11.7895 20C11.2081 20 10.7368 19.5287 10.7368 18.9474L10.7368 5.05263C10.7368 4.47128 11.2081 4 11.7895 4Z"
fill="#F4F4F4"
className="icon-color"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20 11.7895C20 12.3708 19.5287 12.8421 18.9474 12.8421L5.05263 12.8421C4.47128 12.8421 4 12.3708 4 11.7895C4 11.2081 4.47128 10.7368 5.05263 10.7368L18.9474 10.7368C19.5287 10.7368 20 11.2081 20 11.7895Z"
fill="#F4F4F4"
className="icon-color"
/>
</svg>
)
export default icon
@@ -0,0 +1,14 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<path
fill="#B2B5B2"
fillRule="evenodd"
className="icon-color"
d="M7 10c-1.654 0-3-1.346-3-3s1.346-3 3-3 3 1.346 3 3-1.346 3-3 3m6.068-4.209l-.3-.03c-.683-.068-1-.437-1.137-.67-.068-.255-.11-.74.316-1.286l.199-.242c.54-.607.354-.963-.035-1.352l-.322-.32c-.39-.39-.744-.576-1.353-.037l-.235.192c-.535.44-1.025.397-1.285.328-.232-.135-.609-.45-.677-1.14l-.03-.3C8.16.12 7.777 0 7.228 0h-.456c-.55 0-.932.121-.98.933l-.03.31c-.084.686-.458 1-.686 1.133-.261.068-.747.105-1.277-.33l-.235-.192c-.609-.539-.964-.353-1.353.036l-.322.321c-.389.39-.575.745-.035 1.352l.2.242c.424.545.383 1.031.315 1.286-.137.233-.454.602-1.137.67l-.3.03c-.811.05-.932.431-.932.981v.456c0 .55.121.933.932.98l.31.03c.686.085 1 .458 1.133.686.068.262.105.748-.33 1.278l-.192.235c-.539.607-.353.963.036 1.352l.322.32c.39.39.744.576 1.353.037l.242-.2c.539-.421 1.02-.384 1.277-.317.229.135.596.449.679 1.128l.03.31c.048.812.43.933.98.933h.456c.55 0 .932-.121.981-.932l.03-.301c.067-.683.437-1 .67-1.137.255-.067.741-.11 1.285.316l.242.2c.609.539.964.354 1.353-.036l.322-.321c.389-.39.575-.745.036-1.352l-.192-.235c-.435-.53-.398-1.016-.33-1.278.133-.228.447-.601 1.133-.686l.31-.03c.811-.047.932-.43.932-.98v-.456c0-.55-.121-.932-.932-.98"
/>
</svg>
)
export default icon
@@ -0,0 +1,13 @@
import React from 'react'
const icon = (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="4" viewBox="0 0 20 4">
<g fill="#B2B5B2" className="icon-color" fillRule="evenodd">
<rect width="4" height="4" x="16" rx="2" />
<rect width="4" height="4" x="8" rx="2" />
<rect width="4" height="4" rx="2" />
</g>
</svg>
)
export default icon
@@ -0,0 +1,74 @@
import React from 'react'
import arrowSort from './images/arrowSort'
import connectedRinkeby from './images/connectedRinkeby'
import connectedWallet from './images/connectedWallet'
import bullit from './images/bullit'
import dropdownArrowSmall from './images/dropdownArrowSmall'
import arrowReceived from './images/arrowReceived'
import arrowReceivedWhite from './images/arrowReceivedWhite'
import arrowSent from './images/arrowSent'
import arrowSentWhite from './images/arrowSentWhite'
import threeDots from './images/threeDots'
import options from './images/options'
import plus from './images/plus'
import chevronRight from './images/chevronRight'
import chevronLeft from './images/chevronLeft'
import chevronUp from './images/chevronUp'
import chevronDown from './images/chevronDown'
import settingsChange from './images/settingsChange'
import creatingInProgress from './images/creatingInProgress'
import notOwner from './images/notOwner'
import notConnected from './images/notConnected'
import networkError from './images/networkError'
import styled from 'styled-components'
const StyledIcon = styled.span`
.icon-color {
fill: ${({ theme }) => theme.palette.text.primary};
}
.icon-stroke {
fill: ${({ theme }) => theme.palette.text.primary};
}
`
const icons = {
arrowSort,
connectedRinkeby,
connectedWallet,
bullit,
dropdownArrowSmall,
arrowReceived,
arrowReceivedWhite,
arrowSent,
arrowSentWhite,
threeDots,
options,
plus,
chevronRight,
chevronLeft,
chevronUp,
chevronDown,
settingsChange,
creatingInProgress,
notOwner,
notConnected,
networkError,
}
export type IconType = typeof icons
export type IconTypes = keyof IconType
type Props = {
type: IconTypes
className?: string
}
/**
* The `FixedIcon` renders an icon
*/
function FixedIcon({ type, className }: Props): React.ReactElement {
return <StyledIcon className={className}>{icons[type]}</StyledIcon>
}
export default FixedIcon
@@ -0,0 +1,135 @@
import React from 'react'
import Modal from '@mui/material/Modal'
import { alpha, styled as muiStyled } from '@mui/material/styles'
import styled from 'styled-components'
import Media from 'react-media'
import { Typography } from '@mui/material'
import { Icon } from './Icon'
const StyledButton = styled.button`
background: none;
border: none;
padding: 5px;
width: 26px;
height: 26px;
span {
margin-right: 0;
}
:focus {
outline: none;
}
:hover {
background: ${({ theme }) => theme.palette.divider};
border-radius: 16px;
}
`
const TitleSection = styled.div`
display: flex;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 2px solid ${({ theme }) => theme.palette.divider};
`
const BodySection = styled.div<{
$withoutBodyPadding?: boolean
$smallHeight: boolean
}>`
max-height: ${({ $smallHeight }) => ($smallHeight ? '280px' : '460px')};
overflow-y: auto;
padding: ${({ $withoutBodyPadding }) => ($withoutBodyPadding ? '0' : '16px 24px')};
`
const FooterSection = styled.div`
border-top: 2px solid ${({ theme }) => theme.palette.divider};
padding: 16px 24px;
`
const ModalPaper = styled.div`
background: ${({ theme }) => theme.palette.background.paper};
color: ${({ theme }) => theme.palette.text.primary};
`
export type GenericModalProps = {
title: string | React.ReactNode
body: React.ReactNode
withoutBodyPadding?: boolean
footer?: React.ReactNode
onClose: () => void
}
const StyledModal = muiStyled(Modal, {
shouldForwardProp: (prop) => prop !== 'smallHeight',
})<{ smallHeight: boolean }>(({ smallHeight }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflowY: 'scroll',
background: alpha('#E8E7E6', 0.75),
'& .MuiModal-paper': {
position: smallHeight ? 'relative' : 'absolute',
top: smallHeight ? 'unset' : '121px',
minWidth: '500px',
width: smallHeight ? '500px' : 'inherit',
borderRadius: '8px',
boxShadow: `0 0 0.75 0 #28363D`,
'&:focus': {
outline: 'none',
},
},
}))
const PaperWrapper = muiStyled('div', {
shouldForwardProp: (prop) => prop !== 'smallHeight',
})<{ smallHeight: boolean }>(({ smallHeight }) => ({
position: smallHeight ? 'relative' : 'absolute',
top: smallHeight ? 'unset' : '121px',
minWidth: '500px',
width: smallHeight ? '500px' : 'inherit',
borderRadius: '8px',
boxShadow: `0 0 0.75 0 #28363D`,
'&:focus': {
outline: 'none',
},
}))
const GenericModalComponent = ({
body,
footer,
onClose,
title,
withoutBodyPadding,
smallHeight,
}: GenericModalProps & { smallHeight: boolean }) => {
return (
<StyledModal open smallHeight={smallHeight}>
<PaperWrapper smallHeight={smallHeight}>
<ModalPaper>
<TitleSection>
<Typography variant="h6">{title}</Typography>
<StyledButton onClick={onClose}>
<Icon size="sm" type="cross" />
</StyledButton>
</TitleSection>
<BodySection $withoutBodyPadding={withoutBodyPadding} $smallHeight={smallHeight}>
{body}
</BodySection>
{footer && <FooterSection>{footer}</FooterSection>}
</ModalPaper>
</PaperWrapper>
</StyledModal>
)
}
const GenericModal = (props: GenericModalProps): React.ReactElement => (
<Media query={{ maxHeight: 500 }}>
{(matches: boolean) => <GenericModalComponent {...props} smallHeight={matches} />}
</Media>
)
export default GenericModal
@@ -0,0 +1,27 @@
import { screen, waitFor } from '@testing-library/react'
import { render } from '../test-utils'
import Header from './Header'
describe('<Header>', () => {
it('Renders Header component', async () => {
render(<Header />)
await waitFor(() => {
expect(screen.getByText('Transaction Builder')).toBeInTheDocument()
})
})
it('Shows Link to Transaction Library in Create Batch pathname', async () => {
render(<Header />)
await waitFor(() => {
expect(screen.getByText('Transaction Builder')).toBeInTheDocument()
expect(
screen.getByText('Your transaction library', {
exact: false,
}),
).toBeInTheDocument()
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import {
CREATE_BATCH_PATH,
EDIT_BATCH_PATH,
HOME_PATH,
SAVE_BATCH_PATH,
TRANSACTION_LIBRARY_PATH,
} from '../routes/routes'
import { useTransactionLibrary } from '../store'
import ChecksumWarning from './ChecksumWarning'
import ErrorAlert from './ErrorAlert'
import { Tooltip } from './Tooltip'
import { Icon } from './Icon'
import FixedIcon from './FixedIcon'
import { Typography } from '@mui/material'
import Text from './Text'
const HELP_ARTICLE_LINK = 'https://help.safe.global/en/articles/234052-transaction-builder'
const goBackLabel: Record<string, string> = {
[CREATE_BATCH_PATH]: 'Back to Transaction Creation',
[TRANSACTION_LIBRARY_PATH]: 'Back to Your Transaction Library',
[EDIT_BATCH_PATH]: 'Back to Edit Batch',
[SAVE_BATCH_PATH]: 'Back to Transaction Creation',
}
type LocationType = {
state: { from: string } | null
}
const Header = () => {
const { pathname } = useLocation()
const navigate = useNavigate()
const goBack = () => navigate(-1)
const { batches } = useTransactionLibrary()
const isTransactionCreationPath = pathname === CREATE_BATCH_PATH
const isSaveBatchPath = pathname === SAVE_BATCH_PATH
const showTitle = isTransactionCreationPath || isSaveBatchPath
const showLinkToLibrary = isTransactionCreationPath || isSaveBatchPath
const { state } = useLocation() as LocationType
const previousUrl = state?.from || CREATE_BATCH_PATH
return (
<>
<HeaderWrapper>
{showTitle ? (
<>
{/* Transaction Builder Title */}
<StyledTitle>Transaction Builder</StyledTitle>
<Tooltip placement="top" title="Help Article" backgroundColor="primary" arrow>
<StyledIconLink href={HELP_ARTICLE_LINK} target="_blank" rel="noreferrer">
<Icon size="md" type="info" />
</StyledIconLink>
</Tooltip>
</>
) : (
<StyledLink to={HOME_PATH} onClick={goBack}>
{/* Go Back link */}
<FixedIcon type={'chevronLeft'} />
<StyledLeftLinkLabel>{goBackLabel[previousUrl]}</StyledLeftLinkLabel>
</StyledLink>
)}
{showLinkToLibrary && (
<RigthLinkWrapper>
<StyledLink to={TRANSACTION_LIBRARY_PATH}>
<StyledRightLinkLabel>{`(${batches.length}) Your transaction library`}</StyledRightLinkLabel>
<FixedIcon type={'chevronRight'} />
</StyledLink>
</RigthLinkWrapper>
)}
</HeaderWrapper>
<ErrorAlert />
<ChecksumWarning />
</>
)
}
export default Header
const HeaderWrapper = styled.header`
position: fixed;
width: 100%;
display: flex;
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.palette.border.light};
z-index: 10;
background-color: ${({ theme }) => theme.palette.background.paper};
color: ${({ theme }) => theme.palette.text.primary};
height: 70px;
padding: 0 40px;
box-sizing: border-box;
`
const StyledTitle = styled(Typography)`
&& {
font-size: 20px;
font-weight: 700;
margin: 0 10px 0 0;
}
`
const StyledLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.palette.common.black};
font-size: 16px;
text-decoration: none;
> span {
padding-top: 3px;
path {
fill: ${({ theme }) => theme.palette.common.black};
}
}
`
const StyledLeftLinkLabel = styled(Text)`
&& {
margin-left: 8px;
font-weight: 700;
}
`
const RigthLinkWrapper = styled.div`
display: flex;
flex-grow: 1;
justify-content: flex-end;
`
const StyledRightLinkLabel = styled(Text)`
&& {
font-weight: 700;
margin-right: 8px;
}
`
const StyledIconLink = styled.a`
display: flex;
align-items: center;
`
@@ -0,0 +1,34 @@
const Alert = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
d="M8 5.996c.553 0 1 .447 1 1v.998c0 .553-.447 1-1 1-.552 0-1-.447-1-1v-.998c0-.553.448-1 1-1M8 9.895c.607 0 1.101.492 1.101 1.1 0 .607-.494 1.1-1.1 1.1-.608 0-1.1-.493-1.1-1.1 0-.608.492-1.1 1.1-1.1"
/>
<path
className="icon-color"
d="M8 0c-.383 0-.766.193-.975.581L.133 13.373c-.396.734.138 1.624.974 1.624h13.786c.836 0 1.37-.89.974-1.624L8.974.581C8.766.193 8.384 0 8 0m0 3l5.386 9.997H2.613L8 3"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
d="M11.999 8.002c.552 0 1 .447 1 1v4c0 .552-.448 1-1 1-.553 0-1-.448-1-1v-4c0-.553.447-1 1-1M11.999 15.752c.69 0 1.25.56 1.25 1.25s-.56 1.25-1.25 1.25-1.25-.56-1.25-1.25.56-1.25 1.25-1.25"
/>
<path
className="icon-color"
d="M12 2c-.502 0-1.004.25-1.283.752L1.187 19.83C.644 20.804 1.352 22 2.47 22H21.53c1.119 0 1.826-1.196 1.283-2.17l-9.53-17.078C13.004 2.25 12.503 2 12 2m0 2.554l8.624 15.454H3.377L12 4.554"
/>
</g>
</svg>
),
}
export default Alert
@@ -0,0 +1,26 @@
const Bookmark = {
sm: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.875 3.2C4.70924 3.2 4.55027 3.26321 4.43306 3.37574C4.31585 3.48826 4.25 3.64087 4.25 3.8V12.2341L7.63673 9.91176C7.85404 9.76275 8.14596 9.76275 8.36327 9.91176L11.75 12.2341V3.8C11.75 3.64087 11.6842 3.48826 11.5669 3.37574C11.4497 3.26321 11.2908 3.2 11.125 3.2H4.875ZM3.54917 2.52721C3.90081 2.18964 4.37772 2 4.875 2H11.125C11.6223 2 12.0992 2.18964 12.4508 2.52721C12.8025 2.86477 13 3.32261 13 3.8V13.4C13 13.6248 12.8692 13.8307 12.661 13.9335C12.4528 14.0363 12.2022 14.0189 12.0117 13.8882L8 11.1373L3.98827 13.8882C3.79776 14.0189 3.54718 14.0363 3.33901 13.9335C3.13084 13.8307 3 13.6248 3 13.4V3.8C3 3.32261 3.19754 2.86477 3.54917 2.52721Z"
fill="black"
className="icon-color"
/>
</svg>
),
md: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
className="icon-color"
fillRule="evenodd"
clipRule="evenodd"
d="M4.875 3.2C4.70924 3.2 4.55027 3.26321 4.43306 3.37574C4.31585 3.48826 4.25 3.64087 4.25 3.8V12.2341L7.63673 9.91176C7.85404 9.76275 8.14596 9.76275 8.36327 9.91176L11.75 12.2341V3.8C11.75 3.64087 11.6842 3.48826 11.5669 3.37574C11.4497 3.26321 11.2908 3.2 11.125 3.2H4.875ZM3.54917 2.52721C3.90081 2.18964 4.37772 2 4.875 2H11.125C11.6223 2 12.0992 2.18964 12.4508 2.52721C12.8025 2.86477 13 3.32261 13 3.8V13.4C13 13.6248 12.8692 13.8307 12.661 13.9335C12.4528 14.0363 12.2022 14.0189 12.0117 13.8882L8 11.1373L3.98827 13.8882C3.79776 14.0189 3.54718 14.0363 3.33901 13.9335C3.13084 13.8307 3 13.6248 3 13.4V3.8C3 3.32261 3.19754 2.86477 3.54917 2.52721Z"
fill="black"
/>
</svg>
),
}
export default Bookmark
@@ -0,0 +1,30 @@
const BookMarkFilled = {
sm: (
<svg height="16" width="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
className="icon-color icon-stroke"
d="M9 11L5 8.22222L1 11V2.11111C1 1.81643 1.12041 1.53381 1.33474 1.32544C1.54906 1.11706 1.83975 1 2.14286 1H7.85714C8.16025 1 8.45094 1.11706 8.66527 1.32544C8.87959 1.53381 9 1.81643 9 2.11111V11Z"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
),
md: (
<svg height="24" width="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fillRule="evenodd">
<path
className="icon-color icon-stroke"
d="M9 11L5 8.22222L1 11V2.11111C1 1.81643 1.12041 1.53381 1.33474 1.32544C1.54906 1.11706 1.83975 1 2.14286 1H7.85714C8.16025 1 8.45094 1.11706 8.66527 1.32544C8.87959 1.53381 9 1.81643 9 2.11111V11Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
),
}
export default BookMarkFilled
@@ -0,0 +1,26 @@
const Check = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
d="M6 13.003c-.267 0-.522-.105-.709-.293L1.292 8.706c-.39-.39-.39-1.024.001-1.415.391-.389 1.024-.39 1.415.001L6 10.588l7.296-7.295c.391-.39 1.023-.39 1.414 0 .391.391.391 1.023 0 1.414L6.706 12.71c-.187.188-.44.293-.707.293"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
d="M8.999 19c-.266 0-.52-.105-.707-.293l-5.999-6.003c-.391-.391-.391-1.024 0-1.414.391-.391 1.023-.391 1.414 0l5.292 5.296L20.291 5.293c.391-.391 1.023-.391 1.414 0s.391 1.023 0 1.414l-11.999 12c-.187.188-.441.293-.707.293"
/>
</g>
</svg>
),
}
export default Check
@@ -0,0 +1,27 @@
const Code = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
fillRule="nonzero"
d="M3.413 7.994l3.293-3.286c.391-.39.392-1.023.002-1.414s-1.022-.392-1.413-.002L1.294 7.284c-.391.39-.392 1.024-.002 1.415l4.002 4.01c.39.391 1.022.392 1.413.002.39-.39.39-1.024 0-1.415L3.414 7.994zM12.585 7.995l-3.292 3.297c-.39.391-.39 1.025 0 1.415.391.39 1.024.39 1.414 0l4-4.006c.39-.39.39-1.024-.001-1.415l-3.993-3.993c-.39-.39-1.024-.39-1.414 0-.39.391-.39 1.025 0 1.415l3.286 3.287z"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
d="M9 19c-.255 0-.511-.099-.706-.293l-6.001-5.999C2.106 12.52 2 12.266 2 12c0-.265.106-.52.293-.707l5.996-6c.39-.39 1.023-.39 1.413 0 .39.391.39 1.023 0 1.414L4.412 12l5.295 5.291c.39.391.39 1.023 0 1.415-.194.195-.45.293-.706.293M15 19c-.256 0-.512-.099-.707-.294-.39-.39-.39-1.023 0-1.414L19.585 12l-5.292-5.293c-.39-.39-.39-1.023 0-1.414.391-.39 1.023-.39 1.414 0l6 6c.39.391.39 1.023 0 1.414l-6 6c-.195.194-.45.292-.707.292"
/>
</g>
</svg>
),
}
export default Code
@@ -0,0 +1,28 @@
const Copy = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
fillRule="nonzero"
d="M10 10h3V3H8v1c1.105 0 2 .895 2 2v4zM6 4V3c0-1.105.895-2 2-2h5c1.105 0 2 .895 2 2v7c0 1.105-.895 2-2 2h-3v1c0 1.105-.895 2-2 2H3c-1.105 0-2-.895-2-2V6c0-1.105.895-2 2-2h3zM3 6v7h5V6H3z"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
fillRule="nonzero"
d="M15 15h4V4h-8v3h2c1.105 0 2 .895 2 2v6zM9 7V4c0-1.105.895-2 2-2h8c1.105 0 2 .895 2 2v11c0 1.105-.895 2-2 2h-4v3c0 1.105-.895 2-2 2H5c-1.105 0-2-.895-2-2V9c0-1.105.895-2 2-2h4zM5 9v11h8V9H5z"
/>
</g>
</svg>
),
}
export default Copy
@@ -0,0 +1,28 @@
import React from 'react'
const Cross = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
d="M9.413 8l5.293 5.292c.39.39.39 1.025 0 1.414-.39.39-1.025.39-1.414 0L8 9.413l-5.293 5.293c-.39.39-1.025.39-1.414 0-.39-.389-.39-1.025 0-1.414L6.585 8 1.292 2.706c-.39-.389-.39-1.025 0-1.414.39-.39 1.024-.39 1.414 0L8 6.585l5.293-5.293c.39-.39 1.024-.39 1.414 0 .39.39.39 1.025 0 1.414L9.413 8z"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
d="M13.413 11.998l8.291 8.291c.39.39.39 1.025 0 1.414-.39.39-1.025.39-1.415 0l-8.291-8.29-8.292 8.29c-.39.39-1.025.39-1.415 0-.389-.389-.389-1.024 0-1.414l8.292-8.291-8.292-8.292c-.389-.39-.389-1.025 0-1.415.39-.389 1.025-.389 1.415 0l8.292 8.292 8.291-8.292c.39-.389 1.025-.389 1.415 0 .39.39.39 1.025 0 1.415l-8.291 8.292z"
/>
</g>
</svg>
),
}
export default Cross
@@ -0,0 +1,54 @@
const Delete = {
sm: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M6.66667 12.0064C7.03533 12.0064 7.33333 11.7084 7.33333 11.3398V7.33976C7.33333 6.9711 7.03533 6.6731 6.66667 6.6731C6.298 6.6731 6 6.9711 6 7.33976V11.3398C6 11.7084 6.298 12.0064 6.66667 12.0064Z"
fill="#A1A3A7"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M9.3339 12.0064C9.70257 12.0064 10.0006 11.7084 10.0006 11.3398V7.33976C10.0006 6.9711 9.70257 6.6731 9.3339 6.6731C8.96524 6.6731 8.66724 6.9711 8.66724 7.33976V11.3398C8.66724 11.7084 8.96524 12.0064 9.3339 12.0064Z"
fill="#A1A3A7"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M11.0084 13.0339C10.9998 13.2033 10.8364 13.3359 10.6771 13.3346H5.31242C5.15909 13.3173 5.00509 13.2039 4.99642 13.0293L4.56442 5.33325H11.4224L11.0084 13.0339ZM6.66442 3.07725C6.66442 2.85459 6.85309 2.66659 7.07576 2.66659H8.92109C9.14776 2.66659 9.33242 2.85125 9.33242 3.07725V3.99992H6.66442V3.07725ZM13.3331 3.99992H12.1744C12.1704 3.99992 12.1664 3.99659 12.1618 3.99659C12.1538 3.99592 12.1471 3.99992 12.1384 3.99992H10.6658V3.07725C10.6658 2.11592 9.88376 1.33325 8.92109 1.33325H7.07576C6.11376 1.33325 5.33109 2.11592 5.33109 3.07725V3.99992H2.66642C2.29842 3.99992 1.99976 4.29792 1.99976 4.66658C1.99976 5.03525 2.29842 5.33325 2.66642 5.33325H3.22976L3.66509 13.0979C3.70709 13.9713 4.43909 14.6686 5.29176 14.6686C5.30376 14.6686 5.31576 14.6679 5.32776 14.6679H10.6611H10.6991C11.5658 14.6679 12.2984 13.9719 12.3404 13.1026L12.7578 5.33325H13.3331C13.7018 5.33325 13.9998 5.03525 13.9998 4.66658C13.9998 4.29792 13.7018 3.99992 13.3331 3.99992Z"
fill="#A1A3A7"
/>
</svg>
),
md: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M6.66667 12.0064C7.03533 12.0064 7.33333 11.7084 7.33333 11.3398V7.33976C7.33333 6.9711 7.03533 6.6731 6.66667 6.6731C6.298 6.6731 6 6.9711 6 7.33976V11.3398C6 11.7084 6.298 12.0064 6.66667 12.0064Z"
fill="#A1A3A7"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M9.3339 12.0064C9.70257 12.0064 10.0006 11.7084 10.0006 11.3398V7.33976C10.0006 6.9711 9.70257 6.6731 9.3339 6.6731C8.96524 6.6731 8.66724 6.9711 8.66724 7.33976V11.3398C8.66724 11.7084 8.96524 12.0064 9.3339 12.0064Z"
fill="#A1A3A7"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M11.0084 13.0339C10.9998 13.2033 10.8364 13.3359 10.6771 13.3346H5.31242C5.15909 13.3173 5.00509 13.2039 4.99642 13.0293L4.56442 5.33325H11.4224L11.0084 13.0339ZM6.66442 3.07725C6.66442 2.85459 6.85309 2.66659 7.07576 2.66659H8.92109C9.14776 2.66659 9.33242 2.85125 9.33242 3.07725V3.99992H6.66442V3.07725ZM13.3331 3.99992H12.1744C12.1704 3.99992 12.1664 3.99659 12.1618 3.99659C12.1538 3.99592 12.1471 3.99992 12.1384 3.99992H10.6658V3.07725C10.6658 2.11592 9.88376 1.33325 8.92109 1.33325H7.07576C6.11376 1.33325 5.33109 2.11592 5.33109 3.07725V3.99992H2.66642C2.29842 3.99992 1.99976 4.29792 1.99976 4.66658C1.99976 5.03525 2.29842 5.33325 2.66642 5.33325H3.22976L3.66509 13.0979C3.70709 13.9713 4.43909 14.6686 5.29176 14.6686C5.30376 14.6686 5.31576 14.6679 5.32776 14.6679H10.6611H10.6991C11.5658 14.6679 12.2984 13.9719 12.3404 13.1026L12.7578 5.33325H13.3331C13.7018 5.33325 13.9998 5.03525 13.9998 4.66658C13.9998 4.29792 13.7018 3.99992 13.3331 3.99992Z"
fill="#A1A3A7"
/>
</svg>
),
}
export default Delete
@@ -0,0 +1,26 @@
const Edit = {
sm: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.7286 6.02792L10.0079 4.29259L11.6539 2.67925L13.3186 4.35859C13.3226 4.36259 13.3153 4.42659 13.3199 4.43059L11.7286 6.02792ZM4.39325 13.3333H2.66659V11.5859L9.05658 5.22725L10.7866 6.97192L4.39325 13.3333ZM14.6666 4.37125C14.6619 4.00059 14.5146 3.65792 14.2653 3.41992L12.6126 1.75325C12.3666 1.49059 12.0159 1.33792 11.6493 1.33325H11.6313C11.2639 1.33325 10.9079 1.48125 10.6526 1.73792L1.52992 10.8359C1.40459 10.9606 1.33325 11.1313 1.33325 11.3086V13.9999C1.33325 14.3686 1.63125 14.6666 1.99992 14.6666H4.66859C4.84525 14.6666 5.01392 14.5973 5.13859 14.4726L14.2653 5.37125C14.5253 5.10859 14.6719 4.74392 14.6666 4.37125Z"
fill="#A1A3A7"
className="icon-color"
/>
</svg>
),
md: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
className="icon-color"
d="M11.7286 6.02792L10.0079 4.29259L11.6539 2.67925L13.3186 4.35859C13.3226 4.36259 13.3153 4.42659 13.3199 4.43059L11.7286 6.02792ZM4.39325 13.3333H2.66659V11.5859L9.05658 5.22725L10.7866 6.97192L4.39325 13.3333ZM14.6666 4.37125C14.6619 4.00059 14.5146 3.65792 14.2653 3.41992L12.6126 1.75325C12.3666 1.49059 12.0159 1.33792 11.6493 1.33325H11.6313C11.2639 1.33325 10.9079 1.48125 10.6526 1.73792L1.52992 10.8359C1.40459 10.9606 1.33325 11.1313 1.33325 11.3086V13.9999C1.33325 14.3686 1.63125 14.6666 1.99992 14.6666H4.66859C4.84525 14.6666 5.01392 14.5973 5.13859 14.4726L14.2653 5.37125C14.5253 5.10859 14.6719 4.74392 14.6666 4.37125Z"
fill="#A1A3A7"
/>
</svg>
),
}
export default Edit
@@ -0,0 +1,36 @@
const ExternalLink = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<path
className="icon-color"
fillRule="nonzero"
d="M13 13v-2c0-.552.448-1 1-1s1 .448 1 1v2c0 1.105-.895 2-2 2H3c-1.105 0-2-.895-2-2V3c0-1.105.895-2 2-2h2c.552 0 1 .448 1 1s-.448 1-1 1H3v10h10z"
/>
<path
className="icon-color"
d="M11.586 3H9c-.552 0-1-.448-1-1s.448-1 1-1h5c.276 0 .526.112.707.293.181.18.293.43.293.707v5c0 .552-.448 1-1 1s-1-.448-1-1V4.414l-6.243 6.243c-.39.39-1.023.39-1.414 0-.39-.39-.39-1.024 0-1.414L11.586 3z"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<path
className="icon-color"
fillRule="nonzero"
d="M20 20v-8c0-.552.448-1 1-1s1 .448 1 1v8c0 1.105-.895 2-2 2H4c-1.105 0-2-.895-2-2V4c0-1.105.895-2 2-2h8c.552 0 1 .448 1 1s-.448 1-1 1H4v16h16z"
/>
<path
className="icon-color"
d="M18.536 4H15.95c-.553 0-1-.448-1-1s.447-1 1-1h5c.276 0 .526.112.707.293.18.18.293.43.293.707v5c0 .552-.448 1-1 1-.553 0-1-.448-1-1V5.414l-9.243 9.243c-.39.39-1.024.39-1.414 0-.39-.39-.39-1.024 0-1.414L18.536 4z"
/>
</g>
</svg>
),
}
export default ExternalLink
@@ -0,0 +1,40 @@
const Import = {
sm: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.66699 10.3893L8.66699 2.00062C8.66699 1.63195 8.36832 1.33395 8.00032 1.33395C7.63166 1.33395 7.33366 1.63195 7.33366 2.00062L7.33366 10.3893L4.58566 7.64195C4.32499 7.38128 3.90299 7.38128 3.64299 7.64195C3.38232 7.90262 3.38232 8.32395 3.64299 8.58462L7.41432 12.3559C7.43366 12.3753 7.45432 12.3933 7.47499 12.4093C7.59766 12.5659 7.78699 12.6653 8.00032 12.6653C8.21299 12.6653 8.40299 12.5659 8.52499 12.4093C8.54566 12.3933 8.56699 12.3753 8.58566 12.3559L12.357 8.58462C12.6177 8.32395 12.6177 7.90262 12.357 7.64195C12.097 7.38128 11.675 7.38128 11.4143 7.64195L8.66699 10.3893Z"
className="icon-color"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.00033 13.3334L14.0003 13.3334C14.367 13.3334 14.667 13.6334 14.667 14.0001C14.667 14.3667 14.367 14.6667 14.0003 14.6667L2.00033 14.6667C1.63299 14.6667 1.33366 14.3667 1.33366 14.0001C1.33366 13.6334 1.63299 13.3334 2.00033 13.3334Z"
className="icon-color"
fill="black"
/>
</svg>
),
md: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.66699 10.3893L8.66699 2.00062C8.66699 1.63195 8.36832 1.33395 8.00032 1.33395C7.63166 1.33395 7.33366 1.63195 7.33366 2.00062L7.33366 10.3893L4.58566 7.64195C4.32499 7.38128 3.90299 7.38128 3.64299 7.64195C3.38232 7.90262 3.38232 8.32395 3.64299 8.58462L7.41432 12.3559C7.43366 12.3753 7.45432 12.3933 7.47499 12.4093C7.59766 12.5659 7.78699 12.6653 8.00032 12.6653C8.21299 12.6653 8.40299 12.5659 8.52499 12.4093C8.54566 12.3933 8.56699 12.3753 8.58566 12.3559L12.357 8.58462C12.6177 8.32395 12.6177 7.90262 12.357 7.64195C12.097 7.38128 11.675 7.38128 11.4143 7.64195L8.66699 10.3893Z"
className="icon-color"
fill="black"
/>
<path
fillRule="evenodd"
className="icon-color"
clipRule="evenodd"
d="M2.00033 13.3334L14.0003 13.3334C14.367 13.3334 14.667 13.6334 14.667 14.0001C14.667 14.3667 14.367 14.6667 14.0003 14.6667L2.00033 14.6667C1.63299 14.6667 1.33366 14.3667 1.33366 14.0001C1.33366 13.6334 1.63299 13.3334 2.00033 13.3334Z"
fill="black"
/>
</svg>
),
}
export default Import
@@ -0,0 +1,40 @@
const Info = {
sm: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fillRule="evenodd">
<path d="M0 0H16V16H0z" />
<rect width="2" height="5" x="7" y="7" className="icon-color" rx="1" />
<path
className="icon-color"
fillRule="nonzero"
d="M8 3.9c.608 0 1.1.492 1.1 1.1 0 .608-.492 1.1-1.1 1.1-.608 0-1.1-.492-1.1-1.1 0-.608.492-1.1 1.1-1.1z"
/>
<path
className="icon-color"
fillRule="nonzero"
d="M8 15c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm0-2c-2.761 0-5-2.239-5-5s2.239-5 5-5 5 2.239 5 5-2.239 5-5 5z"
/>
</g>
</svg>
),
md: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" fillRule="evenodd">
<path d="M0 0H24V24H0z" />
<rect width="2" height="8" x="11" y="10" className="icon-color" rx="1" />
<path
className="icon-color"
fillRule="nonzero"
d="M12 5.75c.69 0 1.25.56 1.25 1.25S12.69 8.25 12 8.25 10.75 7.69 10.75 7s.56-1.25 1.25-1.25z"
/>
<path
className="icon-color"
fillRule="nonzero"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zm0-2c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"
/>
</g>
</svg>
),
}
export default Info
@@ -0,0 +1,26 @@
const TermsOfUse = {
sm: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.50153 1.53477C3.82266 1.19235 4.2582 1 4.71233 1H9.09587C9.25936 1 9.41615 1.06925 9.53176 1.19252L12.8194 4.69815C12.9351 4.82142 13 4.9886 13 5.16293V12.1742C13 12.6583 12.8196 13.1228 12.4985 13.4651C12.4985 13.4652 12.4984 13.4652 12.4984 13.4652C12.4984 13.4653 12.4984 13.4653 12.4983 13.4653C12.1771 13.8079 11.7415 14 11.2876 14H4.71233C4.25827 14 3.82268 13.8078 3.50148 13.4652C3.18035 13.1228 3 12.6583 3 12.1742V2.82585C3 2.3416 3.18041 1.87719 3.50152 1.53478C3.50153 1.53478 3.50153 1.53478 3.50153 1.53477ZM4.71233 2.31461C4.58515 2.31461 4.46321 2.36848 4.37331 2.46434L4.3733 2.46434C4.28339 2.56023 4.23287 2.69026 4.23287 2.82585V12.1742C4.23287 12.3098 4.28341 12.4398 4.37326 12.5356L4.37336 12.5357C4.46318 12.6315 4.58508 12.6854 4.71233 12.6854H11.2876C11.4149 12.6854 11.5368 12.6315 11.6266 12.5358L11.6267 12.5356C11.7166 12.4398 11.7671 12.3097 11.7671 12.1742V5.82024H9.09533C8.93183 5.82024 8.77502 5.75097 8.65941 5.62768C8.54381 5.50439 8.47887 5.33717 8.4789 5.16283L8.47933 2.31461H4.71233ZM9.71206 3.24391L10.8953 4.50563H9.71187L9.71206 3.24391ZM5.19204 5.74749C5.19204 5.38447 5.46803 5.09018 5.80848 5.09018H6.90437C7.24482 5.09018 7.5208 5.38447 7.5208 5.74749C7.5208 6.11051 7.24482 6.4048 6.90437 6.4048H5.80848C5.46803 6.4048 5.19204 6.11051 5.19204 5.74749ZM5.19124 8.08486C5.19124 7.72184 5.46723 7.42756 5.80767 7.42756H10.1912C10.5316 7.42756 10.8076 7.72184 10.8076 8.08486C10.8076 8.44788 10.5316 8.74217 10.1912 8.74217H5.80767C5.46723 8.74217 5.19124 8.44788 5.19124 8.08486ZM5.19124 10.4214C5.19124 10.0584 5.46723 9.76407 5.80767 9.76407H10.1912C10.5316 9.76407 10.8076 10.0584 10.8076 10.4214C10.8076 10.7844 10.5316 11.0787 10.1912 11.0787H5.80767C5.46723 11.0787 5.19124 10.7844 5.19124 10.4214Z"
className="icon-color"
fill="#A1A3A7"
/>
</svg>
),
md: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.50153 1.53477C3.82266 1.19235 4.2582 1 4.71233 1H9.09587C9.25936 1 9.41615 1.06925 9.53176 1.19252L12.8194 4.69815C12.9351 4.82142 13 4.9886 13 5.16293V12.1742C13 12.6583 12.8196 13.1228 12.4985 13.4651C12.4985 13.4652 12.4984 13.4652 12.4984 13.4652C12.4984 13.4653 12.4984 13.4653 12.4983 13.4653C12.1771 13.8079 11.7415 14 11.2876 14H4.71233C4.25827 14 3.82268 13.8078 3.50148 13.4652C3.18035 13.1228 3 12.6583 3 12.1742V2.82585C3 2.3416 3.18041 1.87719 3.50152 1.53478C3.50153 1.53478 3.50153 1.53478 3.50153 1.53477ZM4.71233 2.31461C4.58515 2.31461 4.46321 2.36848 4.37331 2.46434L4.3733 2.46434C4.28339 2.56023 4.23287 2.69026 4.23287 2.82585V12.1742C4.23287 12.3098 4.28341 12.4398 4.37326 12.5356L4.37336 12.5357C4.46318 12.6315 4.58508 12.6854 4.71233 12.6854H11.2876C11.4149 12.6854 11.5368 12.6315 11.6266 12.5358L11.6267 12.5356C11.7166 12.4398 11.7671 12.3097 11.7671 12.1742V5.82024H9.09533C8.93183 5.82024 8.77502 5.75097 8.65941 5.62768C8.54381 5.50439 8.47887 5.33717 8.4789 5.16283L8.47933 2.31461H4.71233ZM9.71206 3.24391L10.8953 4.50563H9.71187L9.71206 3.24391ZM5.19204 5.74749C5.19204 5.38447 5.46803 5.09018 5.80848 5.09018H6.90437C7.24482 5.09018 7.5208 5.38447 7.5208 5.74749C7.5208 6.11051 7.24482 6.4048 6.90437 6.4048H5.80848C5.46803 6.4048 5.19204 6.11051 5.19204 5.74749ZM5.19124 8.08486C5.19124 7.72184 5.46723 7.42756 5.80767 7.42756H10.1912C10.5316 7.42756 10.8076 7.72184 10.8076 8.08486C10.8076 8.44788 10.5316 8.74217 10.1912 8.74217H5.80767C5.46723 8.74217 5.19124 8.44788 5.19124 8.08486ZM5.19124 10.4214C5.19124 10.0584 5.46723 9.76407 5.80767 9.76407H10.1912C10.5316 9.76407 10.8076 10.0584 10.8076 10.4214C10.8076 10.7844 10.5316 11.0787 10.1912 11.0787H5.80767C5.46723 11.0787 5.19124 10.7844 5.19124 10.4214Z"
className="icon-color"
fill="#A1A3A7"
/>
</svg>
),
}
export default TermsOfUse
@@ -0,0 +1,77 @@
import React from 'react'
import styled from 'styled-components'
import { type Theme } from '@mui/material/styles'
import { Tooltip } from '../Tooltip'
import alert from './images/alert'
import bookmark from './images/bookmark'
import bookmarkFilled from './images/bookmarkFilled'
import check from './images/check'
import code from './images/code'
import copy from './images/copy'
import cross from './images/cross'
import deleteIcon from './images/delete'
import edit from './images/edit'
import externalLink from './images/externalLink'
import importImg from './images/import'
import info from './images/info'
import termsOfUse from './images/termsOfUse'
const StyledIcon = styled.span<{ color?: keyof Theme['palette'] }>`
display: inline-flex;
.icon-color {
fill: ${({ theme, color }) => (color ? theme.palette[color].main : '#B2B5B2')};
}
.icon-stroke {
stroke: ${({ theme, color }) => (color ? theme.palette[color].main : '#B2B5B2')};
}
`
const icons = {
alert,
bookmark,
bookmarkFilled,
check,
copy,
code,
cross,
delete: deleteIcon,
edit,
externalLink,
importImg,
info,
termsOfUse,
}
export type IconType = typeof icons
export type IconTypes = keyof IconType
export type IconProps = {
type: IconTypes
size: 'sm' | 'md'
color?: keyof Theme['palette']
tooltip?: string
className?: string
}
/**
* The `Icon` renders an icon, it can be one already defined specified by
* the type Iconprops or custom one using the customUrl.
*/
export const Icon = ({ type, size, color, tooltip, className }: IconProps): React.ReactElement => {
const IconElement = (
<StyledIcon color={color} className={className}>
{icons[type][size]}
</StyledIcon>
)
return tooltip === undefined ? (
IconElement
) : (
<Tooltip title={tooltip} placement="top">
{IconElement}
</Tooltip>
)
}
@@ -0,0 +1,73 @@
import React from 'react'
import styled from 'styled-components'
import { type Theme } from '@mui/material/styles'
import { Icon, IconProps, IconType } from '../Icon'
import Text from '../Text'
const iconTextMargins = {
xxs: '4px',
xs: '6px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
xxl: '24px',
}
type IconMargins = keyof typeof iconTextMargins
type Props = {
iconType: keyof IconType
iconSize: IconProps['size']
iconColor?: keyof Theme['palette']
margin?: IconMargins
color?: keyof Theme['palette']
text: string
className?: string
iconSide?: 'left' | 'right'
}
const LeftIconText = styled.div<{ margin: IconMargins }>`
display: flex;
align-items: center;
svg {
margin: 0 ${({ margin }) => iconTextMargins[margin]} 0 0;
}
`
const RightIconText = styled.div<{ margin: IconMargins }>`
display: flex;
align-items: center;
svg {
margin: 0 0 0 ${({ margin }) => iconTextMargins[margin]};
}
`
/**
* The `IconText` renders an icon next to a text
*/
const IconText = ({
iconSize,
margin = 'xs',
iconType,
iconColor,
text,
iconSide = 'left',
color,
className,
}: Props): React.ReactElement => {
return iconSide === 'right' ? (
<RightIconText className={className} margin={margin}>
<Text color={color}>{text}</Text>
<Icon size={iconSize} type={iconType} color={iconColor} />
</RightIconText>
) : (
<LeftIconText className={className} margin={margin}>
<Icon size={iconSize} type={iconType} color={iconColor} />
<Text color={color}>{text}</Text>
</LeftIconText>
)
}
export default IconText
@@ -0,0 +1,21 @@
import React from 'react'
import styled from 'styled-components'
import { type Theme } from '@mui/material/styles'
export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
color?: keyof Theme['palette'] | 'white'
}
const StyledLink = styled.a<Props>`
cursor: pointer;
color: ${({ theme, color = 'primary' }) =>
color === 'white' ? theme.palette.common.white : theme.palette[color].dark};
font-family: ${({ theme }) => theme.typography.fontFamily};
text-decoration: underline;
`
const Link: React.FC<Props> = ({ children, ...rest }): React.ReactElement => {
return <StyledLink {...rest}>{children}</StyledLink>
}
export default Link
@@ -0,0 +1,34 @@
import React from 'react'
import styled from 'styled-components'
import CircularProgress from '@mui/material/CircularProgress'
import { type Theme } from '@mui/material/styles'
const loaderSizes = {
xxs: '10px',
xs: '16px',
sm: '30px',
md: '50px',
lg: '70px',
}
type Props = {
size: keyof typeof loaderSizes
color?: keyof Theme['palette']
className?: string
}
const StyledCircularProgress = styled(
({ size, className }: Props): React.ReactElement => (
<CircularProgress size={loaderSizes[size]} className={className} />
),
)`
&.MuiCircularProgress-colorPrimary {
color: ${({ theme, color = 'primary' }) => theme.palette[color].main};
}
`
const Loader = ({ className, size, color }: Props): React.ReactElement => (
<StyledCircularProgress size={size} color={color} className={className} />
)
export default Loader
@@ -0,0 +1,48 @@
import MuiAlert from '@mui/lab/Alert'
import MuiAlertTitle from '@mui/lab/AlertTitle'
import styled from 'styled-components'
import { Icon } from './Icon'
type QuickTipProps = {
onClose: () => void
}
const QuickTip = ({ onClose }: QuickTipProps) => {
return (
<StyledAlert severity="success" onClose={onClose} icon={false}>
<StyledTitle>Quick Tip</StyledTitle>
You can save your batches in your transaction library{' '}
<StyledIcon size="sm" type="bookmark" color="primary" aria-label="Save to Library" /> (local browser storage) or{' '}
<StyledIcon size="sm" type="importImg" color="primary" aria-label="Download" /> download the .json file to use
them later.
</StyledAlert>
)
}
const StyledAlert = styled(MuiAlert)`
&& {
font-size: 14px;
padding: 24px;
background: ${({ theme }) => theme.palette.secondary.background};
color: ${({ theme }) => theme.palette.text.primary};
border-radius: 8px;
.MuiAlert-action {
align-items: flex-start;
}
}
`
const StyledTitle = styled(MuiAlertTitle)`
&& {
font-size: 14px;
font-weight: bold;
}
`
const StyledIcon = styled(Icon)`
position: relative;
top: 3px;
`
export default QuickTip
@@ -0,0 +1,37 @@
import { useState, SyntheticEvent } from 'react'
import Link from './Link'
type ShowMoreTextProps = {
children: string
moreLabel?: string
lessLabel?: string
splitIndex?: number
}
const SHOW_MORE = 'Show more'
const SHOW_LESS = 'Show less'
export const ShowMoreText = ({
children,
moreLabel = SHOW_MORE,
lessLabel = SHOW_LESS,
splitIndex = 50,
}: ShowMoreTextProps) => {
const [expanded, setExpanded] = useState(false)
const handleToggle = (event: SyntheticEvent) => {
event.preventDefault()
setExpanded(!expanded)
}
if (children.length < splitIndex) {
return <span>{children}</span>
}
return (
<>
{expanded ? `${children} ` : `${children.substr(0, splitIndex)} ... `}
<Link onClick={handleToggle}>{expanded ? lessLabel : moreLabel}</Link>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import SwitchMui from '@mui/material/Switch'
import styled from 'styled-components'
import { alpha } from '@mui/material/styles'
const StyledSwitch = styled(({ ...rest }) => <SwitchMui {...rest} />)`
&& {
.MuiSwitch-thumb {
background: ${({ theme, checked }) => (checked ? '#12FF80' : theme.palette.common.white)};
box-shadow:
1px 1px 2px rgba(0, 0, 0, 0.2),
0 0 1px rgba(0, 0, 0, 0.5);
}
.MuiSwitch-track {
background: ${({ theme }) => theme.palette.common.black};
}
.MuiIconButton-label,
.MuiSwitch-colorSecondary.Mui-checked {
color: ${({ checked, theme }) => (checked ? theme.palette.secondary.dark : '#B2B5B2')};
}
.MuiSwitch-colorSecondary.Mui-checked:hover {
background-color: ${({ theme }) => alpha(theme.palette.secondary.dark, 0.08)};
}
.Mui-checked + .MuiSwitch-track {
background-color: ${({ theme }) => theme.palette.secondary.dark};
}
}
`
type Props = {
checked: boolean
onChange: (checked: boolean) => void
}
const Switch = ({ checked, onChange }: Props): React.ReactElement => {
const onSwitchChange = (_event: React.ChangeEvent<HTMLInputElement>, checked: boolean) => onChange(checked)
return <StyledSwitch checked={checked} onChange={onSwitchChange} />
}
export default Switch
+63
View File
@@ -0,0 +1,63 @@
import React from 'react'
import MuiTooltip from '@mui/material/Tooltip'
import { alpha, styled as muiStyled, type Theme } from '@mui/material/styles'
import { Typography, TypographyProps } from '@mui/material'
type Props = {
children: React.ReactNode
tooltip?: string
color?: keyof Theme['palette'] | 'white'
className?: string
component?: 'span' | 'p'
strong?: boolean
center?: boolean
}
const StyledTooltip = muiStyled(MuiTooltip)(({ theme }) => ({
'& .MuiTooltip-tooltip': {
backgroundColor: theme.palette.common.white,
color: theme.palette.text.primary,
boxShadow: `0px 0px 10px ${alpha('#28363D', 0.2)}`,
},
'& .MuiTooltip-arrow': {
color: theme.palette.common.white,
},
}))
const Text = ({
children,
component = 'p',
tooltip,
color,
strong,
center,
className,
...rest
}: Props & Omit<TypographyProps, 'color'>): React.ReactElement => {
const textColor = color ? (color === 'white' ? 'common.white' : `${color}.main`) : 'text.primary'
const TextElement = (
<Typography
component={component}
className={className}
sx={{
color: textColor,
textAlign: center ? 'center' : undefined,
fontWeight: strong ? 'bold' : undefined,
}}
{...rest}
>
{children}
</Typography>
)
return tooltip === undefined ? (
TextElement
) : (
<StyledTooltip title={tooltip} placement="bottom" arrow>
{TextElement}
</StyledTooltip>
)
}
export default Text
+74
View File
@@ -0,0 +1,74 @@
import React from 'react'
import styled from 'styled-components'
type SizeType = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type Props = {
children: string | React.ReactNode
size: SizeType
withoutMargin?: boolean
strong?: boolean
}
const StyledH1 = styled.h1<{ $withoutMargin?: boolean; $strong?: boolean }>`
font-family: ${({ theme }) => theme.legacy?.fonts?.fontFamily || theme.typography?.fontFamily};
font-size: ${({ theme }) => theme.legacy?.title?.size?.xl?.fontSize || '32px'};
line-height: ${({ theme }) => theme.legacy?.title?.size?.xl?.lineHeight || '40px'};
font-weight: ${({ $strong }) => ($strong ? 'bold' : 'normal')};
margin: ${({ $withoutMargin }) => ($withoutMargin ? 0 : '30px')} 0;
`
const StyledH2 = styled.h2<{ $withoutMargin?: boolean; $strong?: boolean }>`
font-family: ${({ theme }) => theme.legacy?.fonts?.fontFamily || theme.typography?.fontFamily};
font-size: ${({ theme }) => theme.legacy?.title?.size?.lg?.fontSize || '24px'};
line-height: ${({ theme }) => theme.legacy?.title?.size?.lg?.lineHeight || '32px'};
font-weight: ${({ $strong }) => ($strong ? 'bold' : 'normal')};
margin: ${({ $withoutMargin }) => ($withoutMargin ? 0 : '28px')} 0;
`
const StyledH3 = styled.h3<{ $withoutMargin?: boolean; $strong?: boolean }>`
font-family: ${({ theme }) => theme.legacy?.fonts?.fontFamily || theme.typography?.fontFamily};
font-size: ${({ theme }) => theme.legacy?.title?.size?.md?.fontSize || '20px'};
line-height: ${({ theme }) => theme.legacy?.title?.size?.md?.lineHeight || '28px'};
font-weight: ${({ $strong }) => ($strong ? 'bold' : 'normal')};
margin: ${({ $withoutMargin }) => ($withoutMargin ? 0 : '26px')} 0;
`
const StyledH4 = styled.h4<{ $withoutMargin?: boolean; $strong?: boolean }>`
font-family: ${({ theme }) => theme.legacy?.fonts?.fontFamily || theme.typography?.fontFamily};
font-size: ${({ theme }) => theme.legacy?.title?.size?.sm?.fontSize || '16px'};
line-height: ${({ theme }) => theme.legacy?.title?.size?.sm?.lineHeight || '24px'};
font-weight: ${({ $strong }) => ($strong ? 'bold' : 'normal')};
margin: ${({ $withoutMargin }) => ($withoutMargin ? 0 : '22px')} 0;
`
const StyledH5 = styled.h5<{ $withoutMargin?: boolean; $strong?: boolean }>`
font-family: ${({ theme }) => theme.legacy?.fonts?.fontFamily || theme.typography?.fontFamily};
font-size: ${({ theme }) => theme.legacy?.title?.size?.xs?.fontSize || '14px'};
line-height: ${({ theme }) => theme.legacy?.title?.size?.xs?.lineHeight || '20px'};
font-weight: ${({ $strong }) => ($strong ? 'bold' : 'normal')};
margin: ${({ $withoutMargin }) => ($withoutMargin ? 0 : '18px')} 0;
`
const Title = ({ children, size, withoutMargin, strong }: Props) => {
const transientProps = { $withoutMargin: withoutMargin, $strong: strong }
switch (size) {
case 'xl': {
return <StyledH1 {...transientProps}>{children}</StyledH1>
}
case 'lg': {
return <StyledH2 {...transientProps}>{children}</StyledH2>
}
case 'md': {
return <StyledH3 {...transientProps}>{children}</StyledH3>
}
case 'sm': {
return <StyledH4 {...transientProps}>{children}</StyledH4>
}
case 'xs': {
return <StyledH5 {...transientProps}>{children}</StyledH5>
}
}
}
export default Title
+107
View File
@@ -0,0 +1,107 @@
import { ReactElement } from 'react'
import MUITooltip, { TooltipProps as TooltipPropsMui } from '@mui/material/Tooltip'
import { styled, alpha, type Theme } from '@mui/material/styles'
import { PaletteColor } from '@mui/material/styles/createPalette'
type SizeType = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type TooltipProps = {
size?: SizeType
backgroundColor?: keyof Theme['palette']
textColor?: keyof Theme['palette']
padding?: string
border?: string
}
const getPaddingBySize = (size: SizeType): string => {
switch (size) {
case 'lg':
return '8px 16px'
default:
return '4px 8px'
}
}
const getBorderBySize = (size: SizeType): string => {
switch (size) {
case 'lg':
return 'none'
default:
return `1px solid #B2B5B2`
}
}
const getFontInfoBySize = (
size: SizeType,
): {
fontSize: string
lineHeight: string
} => {
switch (size) {
case 'lg':
return {
fontSize: '14px',
lineHeight: '20px',
}
default:
return {
fontSize: '12px',
lineHeight: '16px',
}
}
}
interface StyledTooltipProps {
backgroundColor?: keyof Theme['palette']
textColor?: keyof Theme['palette']
tooltipSize?: SizeType
}
const StyledTooltip = styled(MUITooltip, {
shouldForwardProp: (prop) => !['backgroundColor', 'textColor', 'tooltipSize'].includes(prop as string),
})<StyledTooltipProps>(({ theme, backgroundColor, textColor, tooltipSize = 'md' }) => ({
'& .MuiTooltip-popper': {
zIndex: 2001,
},
'& .MuiTooltip-tooltip': {
backgroundColor:
backgroundColor && theme.palette[backgroundColor]
? (theme.palette[backgroundColor] as PaletteColor).main
: theme.palette.primary.main,
boxShadow: `1px 2px 10px ${alpha('#28363D', 0.18)}`,
border: getBorderBySize(tooltipSize),
color: textColor ? (theme.palette[textColor] as PaletteColor).main : theme.palette.background.default,
borderRadius: '4px',
fontFamily: theme.typography.fontFamily,
padding: getPaddingBySize(tooltipSize),
fontSize: getFontInfoBySize(tooltipSize).fontSize,
lineHeight: getFontInfoBySize(tooltipSize).lineHeight,
},
'& .MuiTooltip-arrow': {
color: backgroundColor ? (theme.palette[backgroundColor] as PaletteColor).main : '#E8E7E6',
border: 'none',
'&::before': {
boxShadow: `1px 2px 10px ${alpha('#28363D', 0.18)}`,
},
},
}))
type Props = {
title: string
children: ReactElement
} & TooltipProps
export const Tooltip = ({
title,
backgroundColor,
textColor,
children,
size,
...rest
}: Props & Omit<TooltipPropsMui, 'title'>): ReactElement => {
return (
<StyledTooltip title={title} backgroundColor={backgroundColor} textColor={textColor} tooltipSize={size} {...rest}>
{children}
</StyledTooltip>
)
}
@@ -0,0 +1,350 @@
import { AccordionDetails, IconButton } from '@mui/material'
import { memo, useState } from 'react'
import { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd'
import styled from 'styled-components'
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
import { ProposedTransaction } from '../typings/models'
import TransactionDetails from './TransactionDetails'
import { getTransactionText } from '../utils'
import Text from './Text'
import { Accordion, AccordionSummary } from './Accordion'
import { Tooltip } from './Tooltip'
import EthHashInfo from './ETHHashInfo'
import { Icon } from './Icon'
import FixedIcon from './FixedIcon'
import Dot from './Dot'
const UNKNOWN_POSITION_LABEL = '?'
const minArrowSize = '12'
type TransactionProps = {
transaction: ProposedTransaction
provided: DraggableProvided
snapshot: DraggableStateSnapshot
isLastTransaction: boolean
showTransactionDetails: boolean
index: number
draggableTxIndexDestination: number | undefined
draggableTxIndexOrigin: number | undefined
reorderTransactions?: (sourceIndex: number, destinationIndex: number) => void
networkPrefix: string | undefined
replaceTransaction?: (newTransaction: ProposedTransaction, index: number) => void
setTxIndexToEdit: (index: string) => void
openEditTxModal: () => void
removeTransaction?: (index: number) => void
setTxIndexToRemove: (index: string) => void
openDeleteTxModal: () => void
}
const TransactionBatchListItem = memo(
({
transaction,
provided,
snapshot,
isLastTransaction,
showTransactionDetails,
index,
draggableTxIndexDestination,
draggableTxIndexOrigin,
reorderTransactions,
networkPrefix,
replaceTransaction,
setTxIndexToEdit,
openEditTxModal,
removeTransaction,
setTxIndexToRemove,
openDeleteTxModal,
}: TransactionProps) => {
const { description } = transaction
const { to } = description
const transactionDescription = getTransactionText(description)
const [isTxExpanded, setTxExpanded] = useState(false)
const onClickShowTransactionDetails = () => {
if (showTransactionDetails) {
setTxExpanded((isTxExpanded) => !isTxExpanded)
}
}
const isThisTxBeingDragging = snapshot.isDragging
const showArrowAdornment = !isLastTransaction && !isThisTxBeingDragging
// displayed order can change if the user uses the drag and drop feature
const displayedTxPosition = getDisplayedTxPosition(
index,
isThisTxBeingDragging,
draggableTxIndexDestination,
draggableTxIndexOrigin,
)
return (
<TransactionListItem ref={provided.innerRef} {...provided.draggableProps}>
{/* Transacion Position */}
<PositionWrapper>
<PositionDot color="primary" isDragging={isThisTxBeingDragging}>
<Text>{displayedTxPosition}</Text>
</PositionDot>
{showArrowAdornment && <ArrowAdornment />}
</PositionWrapper>
{/* Transaction Description */}
<StyledAccordion
expanded={isTxExpanded}
compact
onChange={onClickShowTransactionDetails}
isDragging={isThisTxBeingDragging}
TransitionProps={{ unmountOnExit: true }}
>
<div {...provided.dragHandleProps}>
<AccordionSummary expandIcon={false} style={{ cursor: reorderTransactions ? 'grab' : 'pointer' }}>
{/* Drag & Drop Indicator */}
{reorderTransactions && (
<Tooltip placement="top" title="Drag and Drop" backgroundColor="primary" arrow>
<DragAndDropIndicatorIcon fontSize="small" />
</Tooltip>
)}
{/* Destination Address label */}
<StyledEthHashInfo shortName={networkPrefix || ''} hash={to} shortenHash={4} shouldShowShortName />
{/* Transaction Description label */}
<TransactionsDescription>{transactionDescription}</TransactionsDescription>
{/* Transaction Actions */}
{/* Edit transaction */}
{replaceTransaction && (
<Tooltip title="Edit transaction" backgroundColor="primary" arrow>
<TransactionActionButton
size="medium"
aria-label="Edit transaction"
onClick={(event) => {
event.stopPropagation()
setTxIndexToEdit(String(index))
openEditTxModal()
}}
>
<Icon size="sm" type="edit" />
</TransactionActionButton>
</Tooltip>
)}
{/* Delete transaction */}
{removeTransaction && (
<Tooltip placement="top" title="Delete transaction" backgroundColor="primary" arrow>
<TransactionActionButton
onClick={(event) => {
event.stopPropagation()
setTxIndexToRemove(String(index))
openDeleteTxModal()
}}
size="medium"
aria-label="Delete transaction"
>
<Icon size="sm" type="delete" />
</TransactionActionButton>
</Tooltip>
)}
{/* Expand transaction details */}
{showTransactionDetails && (
<Tooltip placement="top" title="Expand transaction details" backgroundColor="primary" arrow>
<TransactionActionButton
onClick={(event) => {
event.stopPropagation()
onClickShowTransactionDetails()
}}
size="medium"
aria-label="Expand transaction details"
>
<StyledArrow isTxExpanded={isTxExpanded} type={'chevronDown'} />
</TransactionActionButton>
</Tooltip>
)}
</AccordionSummary>
</div>
{/* Transaction details */}
<AccordionDetails>
<TransactionDetails transaction={transaction} />
</AccordionDetails>
</StyledAccordion>
</TransactionListItem>
)
},
)
const getDisplayedTxPosition = (
index: number,
isDraggingThisTx: boolean,
draggableTxIndexDestination?: number,
draggableTxIndexOrigin?: number,
): string => {
// we show the correct position in the transaction that is being dragged
if (isDraggingThisTx) {
const isAwayFromDroppableZone = draggableTxIndexDestination === undefined
return isAwayFromDroppableZone ? UNKNOWN_POSITION_LABEL : String(draggableTxIndexDestination + 1)
}
// if a transaction is being dragged, we show the correct position in previous transactions
if (index < Number(draggableTxIndexOrigin)) {
// depending on the current destination we show the correct position
return index >= Number(draggableTxIndexDestination) ? `${index + 2}` : `${index + 1}`
}
// if a transaction is being dragged, we show the correct position in next transactions
if (index > Number(draggableTxIndexOrigin)) {
// depending on the current destination we show the correct position
return index > Number(draggableTxIndexDestination) ? `${index + 1}` : `${index}`
}
// otherwise we show the natural position
return `${index + 1}`
}
const TransactionListItem = styled.li`
display: flex;
margin-bottom: 8px;
`
const StyledArrow = styled(FixedIcon)<{ isTxExpanded: boolean }>`
.icon-color {
fill: #b2b5b2;
}
${({ isTxExpanded }) =>
isTxExpanded &&
`
transform: rotateZ(180deg);
`}
`
const PositionWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 10px 0 0;
`
const PositionDot = styled(Dot).withConfig({
shouldForwardProp: (prop, defaultValidatorFn) => defaultValidatorFn(prop),
})<{ isDragging: boolean }>`
height: 24px;
width: 24px;
min-width: 24px;
background-color: ${({ theme }) => theme.palette.border.light};
transition: background-color 0.5s linear;
`
const ArrowAdornment = styled.div`
position: relative;
border-left: 1px solid ${({ theme }) => theme.palette.border.light};
flex-grow: 1;
margin-top: 8px;
&&::before {
content: ' ';
display: inline-block;
position: absolute;
border-left: 1px solid ${({ theme }) => theme.palette.border.light};
height: ${minArrowSize}px;
bottom: -${minArrowSize}px;
left: -1px;
}
&&::after {
content: ' ';
display: inline-block;
position: absolute;
bottom: -${minArrowSize}px;
left: -4px;
border-width: 0 1px 1px 0;
border-style: solid;
border-color: ${({ theme }) => theme.palette.border.light};
padding: 3px;
transform: rotate(45deg);
}
`
// transaction description styles
const StyledAccordion = styled(Accordion).withConfig({
shouldForwardProp: (prop) => !['isDragging'].includes(prop),
})<{ isDragging: boolean }>`
flex-grow: 1;
&.MuiAccordion-root {
margin-bottom: 0;
border-width: 1px;
border-color: ${({ isDragging, expanded, theme }) =>
isDragging || expanded ? theme.palette.secondary.light : theme.palette.background.paper};
transition: border-color 0.5s linear;
&:hover {
border-color: ${({ theme }) => theme.palette.secondary.light};
.MuiAccordionSummary-root {
background-color: ${({ theme }) => theme.palette.secondary.background};
}
}
}
.MuiAccordionSummary-root {
height: 52px;
padding: 0px 8px;
background-color: ${({ isDragging, theme }) =>
isDragging ? theme.palette.secondary.background : theme.palette.background.paper};
.MuiIconButton-root {
padding: 8px;
}
&.Mui-expanded {
border-width: 1px;
background-color: ${({ theme }) => theme.palette.secondary.background};
border-color: ${({ isDragging, expanded, theme }) =>
isDragging || expanded ? theme.palette.secondary.light : '#e8e7e6'};
}
}
.MuiAccordionSummary-content {
max-width: 100%;
align-items: center;
}
`
const TransactionActionButton = styled(IconButton)`
height: 32px;
width: 32px;
padding: 0;
`
const TransactionsDescription = styled(Text)`
&& {
flex-grow: 1;
padding-left: 24px;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`
const DragAndDropIndicatorIcon = styled(DragIndicatorIcon)`
color: #b2bbc0;
margin-right: 4px;
`
const StyledEthHashInfo = styled(EthHashInfo)`
p {
font-size: 14px;
}
`
export default TransactionBatchListItem
@@ -0,0 +1,187 @@
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
import useElementHeight from '../hooks/useElementHeight/useElementHeight'
import { ProposedTransaction } from '../typings/models'
import { weiToEther } from '../utils'
import EthHashInfo from './ETHHashInfo'
import Text from './Text'
import { Typography } from '@mui/material'
import ButtonLink from './buttons/ButtonLink'
type TransactionDetailsProp = {
transaction: ProposedTransaction
}
const TransactionDetails = ({ transaction }: TransactionDetailsProp) => {
const { description, raw } = transaction
const { to, value, data } = raw
const { contractMethod, contractFieldsValues, customTransactionData, networkPrefix, nativeCurrencySymbol } =
description
const isCustomHexDataTx = !!customTransactionData
const isContractInteractionTx = !!contractMethod && !isCustomHexDataTx
const isTokenTransferTx = !isCustomHexDataTx && !isContractInteractionTx
return (
<Wrapper>
<StyledTxTitle>
{isTokenTransferTx ? `Transfer ${weiToEther(value)} ${nativeCurrencySymbol} to:` : 'Interact with:'}
</StyledTxTitle>
<StyledEthHashInfo shortName={networkPrefix || ''} hash={to} showAvatar showCopyBtn shouldShowShortName />
<TxSummaryContainer>
{/* to address */}
<StyledText color="grey">to (address)</StyledText>
<StyledEthHashInfo shortName={networkPrefix || ''} hash={to} shortenHash={4} showCopyBtn shouldShowShortName />
{/* value */}
<StyledText color="grey">value:</StyledText>
<TxValueLabel>{`${weiToEther(value)} ${nativeCurrencySymbol}`}</TxValueLabel>
{/* data */}
<StyledText color="grey">data:</StyledText>
<TxValueLabel>{data}</TxValueLabel>
{isContractInteractionTx && (
<>
{/* method */}
<StyledText color="grey">method:</StyledText>
<StyledTxValueLabel>{contractMethod.name}</StyledTxValueLabel>
{/* method inputs */}
{contractMethod.inputs.map(({ name, type }, index) => {
const inputName = name || index
const inputLabel = `${inputName} (${type})`
const inputValue = contractFieldsValues?.[inputName]
return (
<React.Fragment key={`${inputLabel}-${index}`}>
{/* input name */}
<StyledMethodNameLabel color="grey" tooltip={inputLabel}>
{inputLabel}
</StyledMethodNameLabel>
{/* input value */}
<TxValueLabel>{inputValue}</TxValueLabel>
</React.Fragment>
)
})}
</>
)}
</TxSummaryContainer>
</Wrapper>
)
}
export default TransactionDetails
const Wrapper = styled.article`
flex-grow: 1;
padding: 0 16px;
user-select: text;
`
const TxSummaryContainer = styled.div`
display: grid;
grid-template-columns: minmax(100px, 2fr) minmax(100px, 5fr);
gap: 4px;
margin-top: 16px;
`
const StyledTxTitle = styled(Typography)`
&& {
font-size: 16px;
margin: 8px 0;
font-weight: bold;
line-height: initial;
}
`
const StyledText = styled(Text)`
&& {
color: ${({ theme }) => theme.palette.text.secondary};
font-weight: 400;
}
`
const StyledMethodNameLabel = styled(Text)`
padding-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const LINE_HEIGHT = 22
const MAX_HEIGHT = 2 * LINE_HEIGHT // 2 lines as max height
const TxValueLabel = ({ children }: { children: React.ReactNode }) => {
const [showMore, setShowMore] = useState(false)
const [showEllipsis, setShowEllipsis] = useState(false)
const { height: containerHeight, elementRef } = useElementHeight<HTMLDivElement>()
// we show the Show more/less button if the height is more than 44px (the height of 2 lines)
const showMoreButton = containerHeight && containerHeight > MAX_HEIGHT
// we show/hide ellipsis at the end of the second line if user clicks on "Show more"
useEffect(() => {
if (showMoreButton && !showMore) {
setShowEllipsis(true)
}
}, [showMoreButton, showMore])
return (
<div ref={elementRef}>
{/* value */}
<StyledTxValueLabel showMore={showMore} showEllipsis={showEllipsis}>
{children}
</StyledTxValueLabel>
{/* show more/less button */}
{showMoreButton && (
<StyledButtonLink color="primary" onClick={() => setShowMore((showMore) => !showMore)}>
{showMore ? 'Show less' : 'Show more'}
</StyledButtonLink>
)}
</div>
)
}
const StyledTxValueLabel = styled(Text).withConfig({
shouldForwardProp: (prop) => !['showMore'].includes(prop) || !['showEllipsis'].includes(prop),
})<{ showMore?: boolean; showEllipsis?: boolean }>`
&& {
max-height: ${({ showMore }) => (showMore ? '100%' : `${MAX_HEIGHT + 1}px`)};
font-size: 14px;
line-break: anywhere;
overflow: hidden;
word-break: break-all;
text-overflow: ellipsis;
${({ showEllipsis, showMore }) =>
!showMore &&
showEllipsis &&
`@supports (-webkit-line-clamp: 2) {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}`}
}
`
const StyledEthHashInfo = styled(EthHashInfo)`
p {
font-size: 14px;
}
`
const StyledButtonLink = styled(ButtonLink)`
padding: 0;
&& > p {
margin 0;
}
`
@@ -0,0 +1,394 @@
import { isValidElement, useMemo, useState } from 'react'
import IconButton from '@mui/material/IconButton'
import styled from 'styled-components'
import {
DragDropContext,
Droppable,
DroppableProvided,
DragStart,
DragUpdate,
DropResult,
Draggable,
DraggableProvided,
DraggableStateSnapshot,
} from '@hello-pangea/dnd'
import { ProposedTransaction } from '../typings/models'
import useModal from '../hooks/useModal/useModal'
import DeleteTransactionModal from './modals/DeleteTransactionModal'
import DeleteBatchModal from './modals/DeleteBatchModal'
import SaveBatchModal from './modals/SaveBatchModal'
import EditTransactionModal from './modals/EditTransactionModal'
import { useNetwork, useTransactionLibrary } from '../store'
import Item from './TransactionBatchListItem'
import VirtualizedList from './VirtualizedList'
import { getTransactionText } from '../utils'
import { EditableLabelProps } from './EditableLabel'
import Text from './Text'
import { Tooltip } from './Tooltip'
import { Icon } from './Icon'
import { Typography } from '@mui/material'
import Dot from './Dot'
type TransactionsBatchListProps = {
transactions: ProposedTransaction[]
showTransactionDetails: boolean
showBatchHeader: boolean
// batch title has multiple types because there are files passing it as a string
// or 2 types of components:
// 1: apps/tx-builder/src/pages/EditTransactionLibrary.tsx
// 2: apps/tx-builder/src/pages/CreateTransactions.tsx
batchTitle?: string | React.ReactElement<EditableLabelProps> | React.ReactElement<{ filename: string }>
removeTransaction?: (index: number) => void
saveBatch?: (name: string, transactions: ProposedTransaction[]) => void
downloadBatch?: (name: string, transactions: ProposedTransaction[]) => void
removeAllTransactions?: () => void
replaceTransaction?: (newTransaction: ProposedTransaction, index: number) => void
reorderTransactions?: (sourceIndex: number, destinationIndex: number) => void
}
const TRANSACTION_LIST_DROPPABLE_ID = 'Transaction_List'
const DROP_EVENT = 'DROP'
const TransactionsBatchList = ({
transactions,
reorderTransactions,
removeTransaction,
removeAllTransactions,
replaceTransaction,
saveBatch,
downloadBatch,
showTransactionDetails,
showBatchHeader,
batchTitle,
}: TransactionsBatchListProps) => {
// we need those states to display the correct position in each tx during the drag & drop
const { batch } = useTransactionLibrary()
const [draggableTxIndexOrigin, setDraggableTxIndexOrigin] = useState<number>()
const [draggableTxIndexDestination, setDraggableTxIndexDestination] = useState<number>()
const { networkPrefix, getAddressFromDomain, nativeCurrencySymbol } = useNetwork()
const onDragStart = ({ source }: DragStart) => {
setDraggableTxIndexOrigin(source.index)
setDraggableTxIndexDestination(source.index)
}
const onDragUpdate = ({ source, destination }: DragUpdate) => {
setDraggableTxIndexOrigin(source.index)
setDraggableTxIndexDestination(destination?.index)
}
// we only perform the reorder if its present
const onDragEnd = ({ reason, source, destination }: DropResult) => {
const sourceIndex = source.index
const destinationIndex = destination?.index
const isDropEvent = reason === DROP_EVENT // because user can cancel the drag & drop
const hasTxPositionChanged = sourceIndex !== destinationIndex && destinationIndex !== undefined
const shouldPerformTxReorder = isDropEvent && hasTxPositionChanged
if (shouldPerformTxReorder) {
reorderTransactions?.(sourceIndex, destinationIndex)
}
setDraggableTxIndexOrigin(undefined)
setDraggableTxIndexDestination(undefined)
}
// 5 modals needed: save batch modal, edit transaction modal, delete batch modal, delete transaction modal, download batch modal
const { open: showDeleteBatchModal, openModal: openClearTransactions, closeModal: closeDeleteBatchModal } = useModal()
const { open: showSaveBatchModal, openModal: openSaveBatchModal, closeModal: closeSaveBatchModal } = useModal()
const { open: showDeleteTxModal, openModal: openDeleteTxModal, closeModal: closeDeleteTxModal } = useModal()
const { open: showEditTxModal, openModal: openEditTxModal, closeModal: closeEditTxModal } = useModal()
const [txIndexToRemove, setTxIndexToRemove] = useState<string>()
const [txIndexToEdit, setTxIndexToEdit] = useState<string>()
const fileName = useMemo(() => {
if (isValidElement(batchTitle)) {
if ('filename' in batchTitle.props) {
return batchTitle.props.filename
} else if (batchTitle.props.children) {
return batchTitle.props.children.toString()
}
return 'Untitled'
}
return batchTitle || 'Untitled'
}, [batchTitle])
return (
<>
<TransactionsBatchWrapper>
{/* Transactions Batch Header */}
{showBatchHeader && (
<TransactionHeader>
{/* Transactions Batch Counter */}
<TransactionCounterDot color="primary">
<Text>{transactions.length}</Text>
</TransactionCounterDot>
{/* Transactions Batch Title */}
{batchTitle && <TransactionsTitle>{batchTitle}</TransactionsTitle>}
{/* Transactions Batch Actions */}
{saveBatch && (
<Tooltip placement="top" title="Save to Library" backgroundColor="primary" arrow>
<StyledHeaderIconButton onClick={openSaveBatchModal}>
<Icon
size="sm"
type={batch ? 'bookmarkFilled' : 'bookmark'}
color="primary"
aria-label="Save to Library"
/>
</StyledHeaderIconButton>
</Tooltip>
)}
{downloadBatch && (
<Tooltip placement="top" title="Download" backgroundColor="primary" arrow>
<StyledHeaderIconButton onClick={() => downloadBatch(fileName, transactions)}>
<Icon size="sm" type="importImg" color="primary" aria-label="Download" />
</StyledHeaderIconButton>
</Tooltip>
)}
{removeAllTransactions && (
<Tooltip placement="top" title="Clear transactions" backgroundColor="primary" arrow>
<StyledHeaderIconButton onClick={openClearTransactions}>
<Icon size="sm" type="delete" color="error" aria-label="Clear transactions" />
</StyledHeaderIconButton>
</Tooltip>
)}
</TransactionHeader>
)}
{/* Standard Transactions List */}
{transactions.length <= 20 && (
<DragDropContext onDragStart={onDragStart} onDragUpdate={onDragUpdate} onDragEnd={onDragEnd}>
<Droppable mode={'standard'} droppableId={TRANSACTION_LIST_DROPPABLE_ID}>
{(provided: DroppableProvided) => (
<TransactionList {...provided.droppableProps} ref={provided.innerRef}>
{transactions.map((transaction: ProposedTransaction, index: number) => (
<Draggable
key={transaction.id}
index={index}
draggableId={transaction.id.toString()}
isDragDisabled={!reorderTransactions}
>
{(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
<Item
key={transaction.id}
transaction={transaction}
provided={provided}
snapshot={snapshot}
isLastTransaction={index === transactions.length - 1}
showTransactionDetails={showTransactionDetails}
index={index}
draggableTxIndexDestination={draggableTxIndexDestination}
draggableTxIndexOrigin={draggableTxIndexOrigin}
reorderTransactions={reorderTransactions}
networkPrefix={networkPrefix}
replaceTransaction={replaceTransaction}
setTxIndexToEdit={setTxIndexToEdit}
openEditTxModal={openEditTxModal}
removeTransaction={removeTransaction}
setTxIndexToRemove={setTxIndexToRemove}
openDeleteTxModal={openDeleteTxModal}
/>
)}
</Draggable>
))}
{provided.placeholder}
</TransactionList>
)}
</Droppable>
</DragDropContext>
)}
{/* Virtualized Transaction List */}
{transactions.length > 20 && (
<DragDropContext onDragStart={onDragStart} onDragUpdate={onDragUpdate} onDragEnd={onDragEnd}>
<Droppable
mode={'virtual'}
droppableId={TRANSACTION_LIST_DROPPABLE_ID}
renderClone={(provided: DraggableProvided, snapshot: DraggableStateSnapshot, rubric) => (
<Item
key={transactions[rubric.source.index].id}
transaction={transactions[rubric.source.index]}
provided={provided}
snapshot={snapshot}
isLastTransaction={rubric.source.index === transactions.length - 1}
showTransactionDetails={showTransactionDetails}
index={rubric.source.index}
draggableTxIndexDestination={draggableTxIndexDestination}
draggableTxIndexOrigin={draggableTxIndexOrigin}
reorderTransactions={reorderTransactions}
networkPrefix={networkPrefix}
replaceTransaction={replaceTransaction}
setTxIndexToEdit={setTxIndexToEdit}
openEditTxModal={openEditTxModal}
removeTransaction={removeTransaction}
setTxIndexToRemove={setTxIndexToRemove}
openDeleteTxModal={openDeleteTxModal}
/>
)}
>
{(provided: DroppableProvided) => (
<TransactionList {...provided.droppableProps} ref={provided.innerRef}>
<VirtualizedList
innerRef={provided.innerRef}
items={transactions}
renderItem={(transaction: ProposedTransaction, index: number) => (
<Draggable
key={transaction.id}
index={index}
draggableId={transaction.id.toString()}
isDragDisabled={!reorderTransactions}
>
{(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
<Item
key={transaction.id}
transaction={transaction}
provided={provided}
snapshot={snapshot}
isLastTransaction={index === transactions.length - 1}
showTransactionDetails={showTransactionDetails}
index={index}
draggableTxIndexDestination={draggableTxIndexDestination}
draggableTxIndexOrigin={draggableTxIndexOrigin}
reorderTransactions={reorderTransactions}
networkPrefix={networkPrefix}
replaceTransaction={replaceTransaction}
setTxIndexToEdit={setTxIndexToEdit}
openEditTxModal={openEditTxModal}
removeTransaction={removeTransaction}
setTxIndexToRemove={setTxIndexToRemove}
openDeleteTxModal={openDeleteTxModal}
/>
)}
</Draggable>
)}
/>
{transactions.length <= 20 && provided.placeholder}
</TransactionList>
)}
</Droppable>
</DragDropContext>
)}
</TransactionsBatchWrapper>
{/* Edit transaction modal */}
{showEditTxModal && (
<EditTransactionModal
txIndex={Number(txIndexToEdit)}
transaction={transactions[Number(txIndexToEdit)]}
onSubmit={(updatedTransaction: ProposedTransaction) => {
closeEditTxModal()
replaceTransaction?.(updatedTransaction, Number(txIndexToEdit))
}}
onDeleteTx={() => {
closeEditTxModal()
removeTransaction?.(Number(txIndexToEdit))
}}
onClose={closeEditTxModal}
networkPrefix={networkPrefix}
getAddressFromDomain={getAddressFromDomain}
nativeCurrencySymbol={nativeCurrencySymbol}
/>
)}
{/* Delete batch modal */}
{showDeleteBatchModal && removeAllTransactions && (
<DeleteBatchModal
count={transactions.length}
onClick={() => {
closeDeleteBatchModal()
removeAllTransactions()
}}
onClose={closeDeleteBatchModal}
/>
)}
{/* Delete a transaction modal */}
{showDeleteTxModal && (
<DeleteTransactionModal
txIndex={Number(txIndexToRemove)}
txDescription={getTransactionText(transactions[Number(txIndexToRemove)]?.description)}
onClick={() => {
closeDeleteTxModal()
removeTransaction?.(Number(txIndexToRemove))
}}
onClose={closeDeleteTxModal}
/>
)}
{/* Save batch modal */}
{showSaveBatchModal && (
<SaveBatchModal
onClick={(name: string) => {
closeSaveBatchModal()
saveBatch?.(name, transactions)
}}
onClose={closeSaveBatchModal}
/>
)}
</>
)
}
export default TransactionsBatchList
// tx positions can change during drag & drop
const TransactionsBatchWrapper = styled.section`
width: 100%;
user-select: none;
`
// batch header styles
const TransactionHeader = styled.header`
display: flex;
align-items: center;
`
const TransactionCounterDot = styled(Dot)`
height: 24px;
width: 24px;
min-width: 24px;
p {
color: ${({ theme }) => theme.palette.background.main};
}
`
const TransactionsTitle = styled(Typography)`
&& {
flex-grow: 1;
margin-left: 14px;
min-width: 0;
font-size: 16px;
line-height: normal;
display: flex;
align-items: center;
}
`
const StyledHeaderIconButton = styled(IconButton)`
&.MuiIconButton-root {
border-radius: 4px;
background-color: ${({ theme }) => theme.palette.code.main};
margin-left: 8px;
}
`
// transactions list styles
const TransactionList = styled.ol`
list-style: none;
padding: 0;
`
@@ -0,0 +1,64 @@
import { memo, useEffect, useState, ReactNode } from 'react'
import { Virtuoso } from 'react-virtuoso'
import styled from 'styled-components'
type VirtualizedListProps<T> = {
innerRef: (ref: HTMLElement | null) => void
items: T[]
renderItem: (item: T, index: number) => ReactNode
}
const VirtualizedList = <T,>({ innerRef, items, renderItem }: VirtualizedListProps<T>) => {
const handleScrollerRef = (ref: HTMLElement | Window | null) => {
if (ref instanceof HTMLElement || ref === null) {
innerRef(ref)
}
}
return (
<Virtuoso
style={{ height: 600 }}
scrollerRef={handleScrollerRef}
data={items}
itemContent={(index, item) => renderItem(item, index)}
components={{
Item: HeightPreservingItem,
}}
totalCount={items.length}
overscan={100}
/>
)
}
interface HeightPreservingItemProps {
children?: ReactNode
'data-known-size'?: number
style?: React.CSSProperties
}
const HeightPreservingItem = memo(({ children, ...props }: HeightPreservingItemProps) => {
const [size, setSize] = useState(0)
const knownSize = props['data-known-size']
useEffect(() => {
setSize((prevSize) => {
return knownSize === 0 ? prevSize : (knownSize ?? 0)
})
}, [knownSize])
return (
<HeightPreservingContainer {...props} size={size}>
{children}
</HeightPreservingContainer>
)
})
const HeightPreservingContainer = styled.div<{ size: number }>`
--child-height: ${(props) => `${props.size}px`};
&:empty {
min-height: calc(var(--child-height));
box-sizing: border-box;
}
`
export default VirtualizedList
@@ -0,0 +1,28 @@
import React from 'react'
import styled from 'styled-components'
function Wrapper({ children, centered }: { children: React.ReactNode; centered?: boolean }) {
return (
<StyledWrapper $centered={centered}>
<section>{children}</section>
</StyledWrapper>
)
}
const StyledWrapper = styled.main<{ $centered?: boolean }>`
width: 100%;
min-height: 100%;
display: flex;
background: ${({ theme }) => theme.palette.background.main};
color: ${({ theme }) => theme.palette.text.primary};
> section {
width: 100%;
padding: 120px 4rem 48px;
box-sizing: border-box;
margin: 0 auto;
max-width: ${({ $centered }) => ($centered ? '1000px' : '1500px')};
}
`
export default Wrapper
@@ -0,0 +1,52 @@
import React from 'react'
import styled from 'styled-components'
import { Icon, IconProps, IconType } from '../../Icon'
import Text from '../../Text'
import { TypographyProps } from '@mui/material'
import { type Theme } from '@mui/material/styles'
export interface Props extends React.ComponentPropsWithoutRef<'button'> {
iconType?: keyof IconType
iconSize?: IconProps['size']
textSize?: TypographyProps['variant']
color: keyof Theme['palette']
children?: React.ReactNode
}
const StyledButtonLink = styled.button<Props>`
background: transparent;
border: none;
text-decoration: none;
cursor: pointer;
color: ${({ theme, color }) => theme.palette[color].main};
font-family: ${({ theme }) => theme.typography.fontFamily};
display: flex;
align-items: center;
:focus {
outline: none;
}
`
const StyledText = styled(Text)`
margin: 0 4px;
`
const ButtonLink = ({
iconType,
iconSize = 'md',
children,
textSize = 'body1',
...rest
}: Props): React.ReactElement => {
return (
<StyledButtonLink {...rest}>
{iconType && <Icon size={iconSize} color={rest.color} type={iconType} />}
<StyledText variant={textSize} color={rest.color}>
{children}
</StyledText>
</StyledButtonLink>
)
}
export default ButtonLink
@@ -0,0 +1,24 @@
const copyTextToClipboard = (text: string): void => {
const listener = (e: ClipboardEvent): void => {
e.preventDefault()
if (e.clipboardData) {
e.clipboardData.setData('text/plain', text)
}
}
const range = document.createRange()
const documentSelection = document.getSelection()
if (!documentSelection) {
return
}
range.selectNodeContents(document.body)
documentSelection.addRange(range)
document.addEventListener('copy', listener)
document.execCommand('copy')
document.removeEventListener('copy', listener)
documentSelection.removeAllRanges()
}
export default copyTextToClipboard
@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import styled from 'styled-components'
import copyTextToClipboard from './copyTextToClipboard'
import { Icon } from '../../Icon'
const StyledButton = styled.button`
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s ease-in-out;
outline-color: transparent;
height: 24px;
width: 24px;
display: flex;
justify-content: center;
align-items: center;
:hover {
background-color: ${({ theme }) => theme.palette.divider};
}
`
type Props = {
textToCopy: string
className?: string
iconType?: Parameters<typeof Icon>[0]['type']
tooltip?: string
tooltipAfterCopy?: string
}
const CopyToClipboardBtn = ({
className,
textToCopy,
iconType = 'copy',
tooltip = 'Copy to clipboard',
}: Props): React.ReactElement => {
const [clicked, setClicked] = useState<boolean>(false)
const copy = () => {
copyTextToClipboard(textToCopy)
setClicked(true)
}
const onButtonClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation()
copy()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
// prevents event from bubbling when `Enter` is pressed
if (event.keyCode === 13) {
event.stopPropagation()
}
copy()
}
const onButtonBlur = (): void => {
setTimeout((): void => setClicked(false), 300)
}
return (
<StyledButton
className={className}
type="button"
onClick={onButtonClick}
onKeyDown={onKeyDown}
onMouseLeave={onButtonBlur}
>
<Icon size="sm" type={iconType} tooltip={clicked ? 'Copied' : tooltip} />
</StyledButton>
)
}
export default CopyToClipboardBtn
@@ -0,0 +1,59 @@
import React from 'react'
import styled from 'styled-components'
import { Icon } from '../../Icon'
import { ExplorerInfo } from '../../ETHHashInfo'
const StyledLink = styled.a`
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s ease-in-out;
outline-color: transparent;
height: 24px;
width: 24px;
display: flex;
justify-content: center;
align-items: center;
:hover {
background-color: #f0efee;
}
`
type Props = {
className?: string
explorerUrl: ExplorerInfo
}
const ExplorerButton = ({ className, explorerUrl }: Props): React.ReactElement => {
const { url, alt } = explorerUrl()
const onClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
event.stopPropagation()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLAnchorElement>): void => {
// prevents event from bubbling when `Enter` is pressed
if (event.keyCode === 13) {
event.stopPropagation()
}
}
return (
<StyledLink
className={className}
aria-label="Show details on Etherscan"
rel="noopener noreferrer"
onClick={onClick}
href={url}
target="_blank"
onKeyDown={onKeyDown}
>
<Icon size="sm" type="externalLink" tooltip={alt} />
</StyledLink>
)
}
export default ExplorerButton
@@ -0,0 +1,32 @@
import * as React from 'react'
import makeBlockie from 'ethereum-blockies-base64'
import styled from 'styled-components'
export const identiconSizes = {
xs: '10px',
sm: '16px',
md: '32px',
lg: '40px',
xl: '48px',
xxl: '60px',
}
type Props = {
address: string
size: keyof typeof identiconSizes
}
const StyledImg = styled.img<{ size: keyof typeof identiconSizes }>`
height: ${({ size }) => identiconSizes[size]};
width: ${({ size }) => identiconSizes[size]};
border-radius: 50%;
`
const Identicon = ({ size = 'md', address, ...rest }: Props): React.ReactElement => {
const iconSrc = React.useMemo(() => makeBlockie(address), [address])
return <StyledImg src={iconSrc} size={size} {...rest} />
}
export default Identicon
@@ -0,0 +1,82 @@
import styled from 'styled-components'
import { ContractInterface } from '../../typings/models'
import { isValidAddress } from '../../utils'
import SolidityForm, {
CONTRACT_METHOD_INDEX_FIELD_NAME,
SolidityFormValuesTypes,
TO_ADDRESS_FIELD_NAME,
parseFormToProposedTransaction,
} from './SolidityForm'
import { useTransactions, useNetwork } from '../../store'
import { Typography } from '@mui/material'
import Button from '../Button'
import FixedIcon from '../FixedIcon'
type AddNewTransactionFormProps = {
contract: ContractInterface | null
to: string
showHexEncodedData: boolean
}
const AddNewTransactionForm = ({ contract, to, showHexEncodedData }: AddNewTransactionFormProps) => {
const initialFormValues = {
[TO_ADDRESS_FIELD_NAME]: isValidAddress(to) ? to : '',
[CONTRACT_METHOD_INDEX_FIELD_NAME]: '0',
}
const { addTransaction } = useTransactions()
const { networkPrefix, getAddressFromDomain, nativeCurrencySymbol } = useNetwork()
const onSubmit = (values: SolidityFormValuesTypes) => {
const proposedTransaction = parseFormToProposedTransaction(values, contract, nativeCurrencySymbol, networkPrefix)
addTransaction(proposedTransaction)
}
return (
<>
<Typography variant="body1" paragraph>
Transaction information
</Typography>
<SolidityForm
id="solidity-contract-form"
initialValues={initialFormValues}
contract={contract}
getAddressFromDomain={getAddressFromDomain}
nativeCurrencySymbol={nativeCurrencySymbol}
networkPrefix={networkPrefix}
onSubmit={onSubmit}
showHexEncodedData={showHexEncodedData}
>
<ButtonContainer>
{/* Add transaction btn */}
<Button variant="contained" color="primary" type="submit">
<FixedIcon type={'plus'} />
<StyledButtonLabel>Add new transaction</StyledButtonLabel>
</Button>
</ButtonContainer>
</SolidityForm>
</>
)
}
export default AddNewTransactionForm
const StyledButtonLabel = styled.span`
margin-left: 8px;
`
const ButtonContainer = styled.div`
display: flex;
justify-content: space-between;
margin-top: 15px;
.MuiButton-root {
padding-left: 10px;
}
span {
display: flex;
}
`
@@ -0,0 +1,165 @@
import { screen, waitFor, act, getByText, fireEvent } from '@testing-library/react'
import { render } from '../../test-utils'
import { ContractInterface } from '../../typings/models'
import SolidityForm, { CONTRACT_METHOD_INDEX_FIELD_NAME, TO_ADDRESS_FIELD_NAME } from './SolidityForm'
// Axios is bundled as ESM module which is not directly compatible with Jest
// https://jestjs.io/docs/ecmascript-modules
jest.mock('axios', () => ({
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
}))
const testAddressMethod = {
inputs: [{ internalType: 'address', name: 'newValue', type: 'address' }],
name: 'testAddressValue',
payable: false,
}
const testBooleanMethod = {
inputs: [{ internalType: 'bool', name: 'newValue', type: 'bool' }],
name: 'testBooleanValue',
payable: false,
}
const initialValues = {
[TO_ADDRESS_FIELD_NAME]: '0x680cde08860141F9D223cE4E620B10Cd6741037E',
[CONTRACT_METHOD_INDEX_FIELD_NAME]: '0',
}
const testContract: ContractInterface = {
methods: [testAddressMethod, testBooleanMethod],
}
describe('<SolidityForm>', () => {
it('Renders SolidityForm component', async () => {
render(
<SolidityForm
id={'test-form'}
onSubmit={jest.fn()}
getAddressFromDomain={jest.fn()}
initialValues={initialValues}
contract={testContract}
nativeCurrencySymbol={'ETH'}
networkPrefix={'rin:'}
showHexEncodedData={false}
>
<button type="submit">submit</button>
</SolidityForm>,
)
await waitFor(() => {
expect(screen.getByTestId('test-form')).toBeInTheDocument()
})
})
it('Show correct field contract params', async () => {
render(
<SolidityForm
id={'test-form'}
onSubmit={jest.fn()}
getAddressFromDomain={jest.fn()}
initialValues={initialValues}
contract={testContract}
nativeCurrencySymbol={'ETH'}
networkPrefix={'rin:'}
showHexEncodedData={false}
>
<button type="submit">submit</button>
</SolidityForm>,
)
let input: ReturnType<typeof screen.getByRole>
// testAddressMethod is selected by default
await waitFor(() => {
input = screen.getByRole('combobox')
expect(input).toHaveValue('testAddressValue')
})
act(() => {
fireEvent.change(input, { target: { value: 'testBooleanVa' } })
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
})
// now testBooleanMethod is selected by default
await waitFor(() => {
// expect(input).toHaveValue('testBooleanValue')
})
})
// see https://github.com/safe-global/safe-react-apps/issues/450
xit('Avoid collisions between parameters with the same name and different types when changing contract methods', async () => {
render(
<SolidityForm
id={'test-form'}
onSubmit={jest.fn()}
getAddressFromDomain={jest.fn()}
initialValues={initialValues}
contract={testContract}
nativeCurrencySymbol={'ETH'}
networkPrefix={'rin:'}
showHexEncodedData={false}
>
<button type="submit">submit</button>
</SolidityForm>,
)
// testAddressMethod is selected by default
await waitFor(() => {
expect(screen.queryByText('testAddressValue')).toBeInTheDocument()
expect(screen.queryByText('testBooleanValue')).not.toBeInTheDocument()
// no value by default
expect(screen.getByTestId('contract-field-newValue')).toHaveValue('')
})
// we update the address field value
await waitFor(() => {
fireEvent.change(screen.getByTestId('contract-field-newValue'), {
target: { value: '0x680cde08860141F9D223cE4E620B10Cd6741037E' },
})
})
// we update the address field value
await waitFor(() => {
expect(screen.getByTestId('contract-field-newValue')).toHaveValue('0x680cde08860141F9D223cE4E620B10Cd6741037E')
})
// selects a different contract method
await waitFor(() => {
const contractMethodSelectorNode = screen.getByTestId('contract-method-selector')
// opens the contract method selector
fireEvent.mouseDown(contractMethodSelectorNode)
// we select the boolean contract method
const selectorModal = screen.getByTestId('menu-contractMethodIndex')
fireEvent.click(getByText(selectorModal, 'testBooleanValue'))
})
// the issue is not present (true value as default for booleans)
await waitFor(() => {
expect(screen.getByTestId('contract-field-newValue-input')).toHaveValue('true')
})
// address value again if we select testAddressMethod again
await waitFor(() => {
const contractMethodSelectorNode = screen.getByTestId('contract-method-selector')
// opens the contract method selector
fireEvent.mouseDown(contractMethodSelectorNode)
// we select the boolean contract method
const selectorModal = screen.getByTestId('menu-contractMethodIndex')
fireEvent.click(getByText(selectorModal, 'testAddressValue'))
})
await waitFor(() => {
expect(screen.getByTestId('contract-field-newValue')).toHaveValue('0x680cde08860141F9D223cE4E620B10Cd6741037E')
})
})
})
@@ -0,0 +1,251 @@
import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { DevTool } from '@hookform/devtools'
import { getAddress, parseEther } from 'ethers'
import {
ADDRESS_FIELD_TYPE,
CONTRACT_METHOD_FIELD_TYPE,
CUSTOM_TRANSACTION_DATA_FIELD_TYPE,
NATIVE_AMOUNT_FIELD_TYPE,
} from './fields/fields'
import Field from './fields/Field'
import { encodeToHexData, getInputTypeHelper } from '../../utils'
import { ContractInterface, ProposedTransaction } from '../../typings/models'
import { isProdEnv } from '../../utils/env'
export const TO_ADDRESS_FIELD_NAME = 'toAddress'
export const NATIVE_VALUE_FIELD_NAME = 'nativeAmount'
export const CONTRACT_METHOD_INDEX_FIELD_NAME = 'contractMethodIndex'
export const CONTRACT_VALUES_FIELD_NAME = 'contractFieldsValues'
export const CUSTOM_TRANSACTION_DATA_FIELD_NAME = 'customTransactionData'
type SolidityFormPropsTypes = {
id: string
networkPrefix: undefined | string
getAddressFromDomain: (name: string) => Promise<string>
nativeCurrencySymbol: undefined | string
contract: ContractInterface | null
onSubmit: SubmitHandler<SolidityFormValuesTypes>
initialValues?: Partial<SolidityInitialFormValuesTypes>
showHexToggler?: boolean
children: React.ReactNode
showHexEncodedData: boolean
}
export type SolidityInitialFormValuesTypes = {
[TO_ADDRESS_FIELD_NAME]: string
[CONTRACT_METHOD_INDEX_FIELD_NAME]: string
}
export type SolidityFormValuesTypes = {
[TO_ADDRESS_FIELD_NAME]: string
[NATIVE_VALUE_FIELD_NAME]: string
[CONTRACT_METHOD_INDEX_FIELD_NAME]: string
[CONTRACT_VALUES_FIELD_NAME]: Record<string, Record<string, string>>
[CUSTOM_TRANSACTION_DATA_FIELD_NAME]: string
}
export const parseFormToProposedTransaction = (
values: SolidityFormValuesTypes,
contract: ContractInterface | null,
nativeCurrencySymbol: string | undefined,
networkPrefix: string | undefined,
): ProposedTransaction => {
const contractMethodIndex = values[CONTRACT_METHOD_INDEX_FIELD_NAME]
const toAddress = values[TO_ADDRESS_FIELD_NAME]
const tokenValue = values[NATIVE_VALUE_FIELD_NAME]
const contractFieldsValues = values[CONTRACT_VALUES_FIELD_NAME]
const methodValues = contractFieldsValues?.[`method-${contractMethodIndex}`]
const customTransactionData = values[CUSTOM_TRANSACTION_DATA_FIELD_NAME]
const contractMethod = contract?.methods[Number(contractMethodIndex)]
const data = customTransactionData || encodeToHexData(contractMethod, methodValues) || '0x'
const to = getAddress(toAddress)
const value = parseEther(tokenValue || '0').toString()
return {
id: new Date().getTime(),
contractInterface: contract,
description: {
to,
value,
customTransactionData,
contractMethod,
contractFieldsValues: methodValues,
contractMethodIndex,
nativeCurrencySymbol,
networkPrefix,
},
raw: { to, value, data },
}
}
const SolidityForm = ({
id,
onSubmit,
getAddressFromDomain,
initialValues,
nativeCurrencySymbol,
networkPrefix,
contract,
children,
showHexEncodedData,
}: SolidityFormPropsTypes) => {
const {
handleSubmit,
control,
setValue,
watch,
getValues,
reset,
formState: { isSubmitSuccessful, dirtyFields },
} = useForm<SolidityFormValuesTypes>({
defaultValues: initialValues,
mode: 'onTouched', // This option allows you to configure the validation strategy before the user submits the form
})
const toAddress = watch(TO_ADDRESS_FIELD_NAME)
const contractMethodIndex = watch(CONTRACT_METHOD_INDEX_FIELD_NAME)
const nativeValue = watch(NATIVE_VALUE_FIELD_NAME)
const customTransactionData = watch(CUSTOM_TRANSACTION_DATA_FIELD_NAME)
const contractMethod = contract?.methods[Number(contractMethodIndex)]
const contractFields = contractMethod?.inputs || []
const showContractFields = !!contract && contract.methods.length > 0 && !showHexEncodedData
const isPayableMethod = !!contract && contractMethod?.payable
const isValueInputVisible = showHexEncodedData || !showContractFields || isPayableMethod
useEffect(() => {
const contractFieldsValues = getValues(CONTRACT_VALUES_FIELD_NAME)
const methodValues = contractFieldsValues?.[`method-${contractMethodIndex}`]
if (showHexEncodedData && contractMethod) {
const encodeData = encodeToHexData(contractMethod, methodValues)
setValue(CUSTOM_TRANSACTION_DATA_FIELD_TYPE, encodeData || '')
}
}, [contractMethod, getValues, setValue, showHexEncodedData, contractMethodIndex])
// Resets form to initial values if the user edited contract method and then switched to custom data and edited it
useEffect(() => {
if (
showHexEncodedData &&
dirtyFields[CONTRACT_METHOD_INDEX_FIELD_NAME] &&
dirtyFields[CUSTOM_TRANSACTION_DATA_FIELD_NAME]
) {
reset({
...initialValues,
[TO_ADDRESS_FIELD_NAME]: toAddress,
[CUSTOM_TRANSACTION_DATA_FIELD_NAME]: customTransactionData,
[NATIVE_VALUE_FIELD_NAME]: nativeValue,
})
}
}, [dirtyFields, reset, showHexEncodedData, customTransactionData, toAddress, nativeValue, initialValues])
useEffect(() => {
if (isSubmitSuccessful) {
reset({ ...initialValues, [TO_ADDRESS_FIELD_NAME]: toAddress })
}
}, [isSubmitSuccessful, reset, toAddress, initialValues])
return (
<>
<form id={id} data-testid={id} onSubmit={handleSubmit(onSubmit)} noValidate>
{/* To Address field */}
<Field
id="to-address-input"
name={TO_ADDRESS_FIELD_NAME}
label="To Address"
fullWidth
required
getAddressFromDomain={getAddressFromDomain}
networkPrefix={networkPrefix}
fieldType={ADDRESS_FIELD_TYPE}
control={control}
showErrorsInTheLabel={false}
/>
{/* Native Token Amount Input */}
{isValueInputVisible && (
<Field
id="token-value-input"
name={NATIVE_VALUE_FIELD_NAME}
label={`${nativeCurrencySymbol} value`}
fieldType={NATIVE_AMOUNT_FIELD_TYPE}
fullWidth
required
control={control}
showErrorsInTheLabel={false}
/>
)}
{/* Contract Section */}
{/* Contract Method Selector */}
{showContractFields && (
<Field
id="contract-method-selector"
name={CONTRACT_METHOD_INDEX_FIELD_NAME}
label="Contract Method Selector"
fieldType={CONTRACT_METHOD_FIELD_TYPE}
shouldUnregister={false}
control={control}
options={contract?.methods.map((method, index) => ({
id: index.toString(),
label: method.name,
}))}
required
/>
)}
{/* Contract Fields */}
{contractFields.map((contractField, index) => {
const name = `${CONTRACT_VALUES_FIELD_NAME}.method-${contractMethodIndex}.${contractField.name || index}`
const fieldType = getInputTypeHelper(contractField)
return (
showContractFields && (
<Field
key={name}
id={`contract-field-${contractField.name || index}`}
name={name}
label={`${contractField.name || `${index + 1}º contract field`} (${fieldType})`}
fieldType={fieldType}
fullWidth
required
shouldUnregister={false} // required to keep contract field values in the form state when the user switches between encoding and decoding data
control={control}
showErrorsInTheLabel={false}
getAddressFromDomain={getAddressFromDomain}
networkPrefix={networkPrefix}
/>
)
)
})}
{/* Hex encoded textarea field */}
{showHexEncodedData && (
<Field
id="hex-encoded-data"
name={CUSTOM_TRANSACTION_DATA_FIELD_NAME}
label="Data (Hex encoded)"
fieldType={CUSTOM_TRANSACTION_DATA_FIELD_TYPE}
required
fullWidth
control={control}
showErrorsInTheLabel={false}
/>
)}
{/* action buttons as a children */}
{children}
</form>
{/* set up the dev tool only in dev env */}
{!isProdEnv() && <DevTool control={control} />}
</>
)
}
export default SolidityForm
@@ -0,0 +1,47 @@
import { ReactElement } from 'react'
import AddressInput from './AddressInput'
interface AddressContractFieldProps {
id: string
name: string
value: string
onChange: (value: string) => void
label: string
error?: string
getAddressFromDomain?: (name: string) => Promise<string>
networkPrefix?: string
onBlur?: () => void
}
const AddressContractField = ({
id,
name,
value,
onChange,
label,
error,
getAddressFromDomain,
networkPrefix,
onBlur,
}: AddressContractFieldProps): ReactElement => {
return (
<AddressInput
id={id}
name={name}
label={label}
address={value}
inputProps={{ value }}
onBlur={onBlur}
showNetworkPrefix={!!networkPrefix}
networkPrefix={networkPrefix}
hiddenLabel={false}
fullWidth
error={error}
getAddressFromDomain={getAddressFromDomain}
onChangeAddress={onChange}
showErrorsInTheLabel={false}
/>
)
}
export default AddressContractField
@@ -0,0 +1,204 @@
import React, { ReactElement, useState, ChangeEvent, useEffect, useCallback, useRef } from 'react'
import InputAdornment from '@mui/material/InputAdornment'
import CircularProgress from '@mui/material/CircularProgress'
import {
addNetworkPrefix,
checksumAddress,
getAddressWithoutNetworkPrefix,
getNetworkPrefix,
isChecksumAddress,
isValidAddress,
isValidEnsName,
} from '../../../utils/address'
import TextFieldInput, { TextFieldInputProps } from './TextFieldInput'
import useThrottle from '../../../hooks/useThrottle'
type AddressInputProps = {
name: string
address: string
networkPrefix?: string
showNetworkPrefix?: boolean
defaultValue?: string
disabled?: boolean
onChangeAddress: (address: string) => void
getAddressFromDomain?: (name: string) => Promise<string>
customENSThrottleDelay?: number
showLoadingSpinner?: boolean
} & TextFieldInputProps
function AddressInput({
name,
address,
networkPrefix,
showNetworkPrefix = true,
disabled,
onChangeAddress,
getAddressFromDomain,
customENSThrottleDelay,
showLoadingSpinner,
InputProps,
inputProps,
hiddenLabel = false,
...rest
}: AddressInputProps): ReactElement {
const [isLoadingENSResolution, setIsLoadingENSResolution] = useState(false)
const defaultInputValue = addPrefix(address, networkPrefix, showNetworkPrefix)
const inputRef = useRef({ value: defaultInputValue })
const throttle = useThrottle()
// we checksum & include the network prefix in the input if showNetworkPrefix is set to true
const updateInputValue = useCallback(
(value = '') => {
if (inputRef.current) {
const checksumAddress = checksumValidAddress(value)
inputRef.current.value = addPrefix(checksumAddress, networkPrefix, showNetworkPrefix)
}
},
[networkPrefix, showNetworkPrefix],
)
const resolveDomainName = useCallback(async () => {
const isEnsName = isValidEnsName(address)
if (isEnsName && getAddressFromDomain) {
try {
setIsLoadingENSResolution(true)
const resolvedAddress = await getAddressFromDomain(address)
onChangeAddress(checksumValidAddress(resolvedAddress))
// we update the input value
updateInputValue(resolvedAddress)
} catch (e) {
onChangeAddress(address)
} finally {
setIsLoadingENSResolution(false)
}
}
}, [address, getAddressFromDomain, onChangeAddress, updateInputValue])
// ENS name resolution
useEffect(() => {
if (getAddressFromDomain) {
throttle(resolveDomainName, customENSThrottleDelay)
}
}, [getAddressFromDomain, resolveDomainName, customENSThrottleDelay, throttle])
// if address changes from outside (Like Loaded from a QR code) we update the input value
useEffect(() => {
const inputValue = inputRef.current?.value
const inputWithoutPrefix = getAddressWithoutNetworkPrefix(inputValue)
const addressWithoutPrefix = getAddressWithoutNetworkPrefix(address)
const inputPrefix = getNetworkPrefix(inputValue)
const addressPrefix = getNetworkPrefix(address)
const isNewAddressLoaded = inputWithoutPrefix !== addressWithoutPrefix
const isNewPrefixLoaded = addressPrefix && inputPrefix !== addressPrefix
// we check if we load a new address (both prefixed and unprefixed cases)
if (isNewAddressLoaded || isNewPrefixLoaded) {
// we update the input value
updateInputValue(address)
}
}, [address, updateInputValue])
// we trim, checksum & remove valid network prefix when a valid address is typed by the user
const updateAddressState = useCallback(
(value: string) => {
const inputValue = value.trim()
const inputPrefix = getNetworkPrefix(inputValue)
const inputWithoutPrefix = getAddressWithoutNetworkPrefix(inputValue)
// if the valid network prefix is present, we remove it from the address state
const isValidPrefix = networkPrefix === inputPrefix
const checksumAddress = checksumValidAddress(isValidPrefix ? inputWithoutPrefix : inputValue)
onChangeAddress(checksumAddress)
},
[networkPrefix, onChangeAddress],
)
// when user switch the network we update the address state
useEffect(() => {
// Because the `address` is going to change after we call `updateAddressState`
// To avoid calling `updateAddressState` twice, we check the value and the current address
const inputValue = inputRef.current?.value
if (inputValue !== address) {
updateAddressState(inputRef.current?.value)
}
}, [networkPrefix, address, updateAddressState])
// when user types we update the address state
function onChange(e: ChangeEvent<HTMLInputElement>) {
updateAddressState(e.target.value)
}
const isLoading = isLoadingENSResolution || showLoadingSpinner
const [shrink, setshrink] = useState(!!defaultInputValue)
useEffect(() => {
setshrink(!!inputRef.current?.value)
}, [inputRef.current.value])
return (
<TextFieldInput
name={name}
hiddenLabel={hiddenLabel && !shrink}
disabled={disabled || isLoadingENSResolution}
onChange={onChange}
InputProps={{
...InputProps,
// if isLoading we show a custom loader adornment
endAdornment: isLoading ? <LoaderSpinnerAdornment /> : InputProps?.endAdornment,
}}
inputProps={{
...inputProps,
ref: inputRef,
}}
InputLabelProps={{
...rest.InputLabelProps,
shrink: shrink || hiddenLabel || undefined,
}}
spellCheck={false}
{...rest}
/>
)
}
export default AddressInput
function LoaderSpinnerAdornment() {
return (
<InputAdornment position="end">
<CircularProgress size="16px" />
</InputAdornment>
)
}
// we only checksum valid addresses
function checksumValidAddress(address: string) {
if (isValidAddress(address) && !isChecksumAddress(address)) {
return checksumAddress(address)
}
return address
}
// we try to add the network prefix if its not present
function addPrefix(address: string, networkPrefix: string | undefined, showNetworkPrefix = false): string {
if (!address) {
return ''
}
if (showNetworkPrefix && networkPrefix) {
const hasPrefix = !!getNetworkPrefix(address)
// if the address has not prefix we add it by default
if (!hasPrefix) {
return addNetworkPrefix(address, networkPrefix)
}
}
return address
}
@@ -0,0 +1,138 @@
import { Control, Controller, FieldValues, FieldPath, PathValue } from 'react-hook-form'
export interface SelectItem {
id: string
label: string
iconUrl?: string
subLabel?: string
}
import {
BOOLEAN_FIELD_TYPE,
CONTRACT_METHOD_FIELD_TYPE,
CUSTOM_TRANSACTION_DATA_FIELD_TYPE,
isAddressFieldType,
isBooleanFieldType,
} from './fields'
import AddressContractField from './AddressContractField'
import SelectContractField from './SelectContractField'
import TextareaContractField from './TextareaContractField'
import TextContractField from './TextContractField'
import validateField, { ValidationFunction } from '../validations/validateField'
const CUSTOM_DEFAULT_VALUES: CustomDefaultValueTypes = {
[BOOLEAN_FIELD_TYPE]: 'true',
[CONTRACT_METHOD_FIELD_TYPE]: '0', // first contract method as default
}
const BOOLEAN_DEFAULT_OPTIONS: SelectItem[] = [
{ id: 'true', label: 'True' },
{ id: 'false', label: 'False' },
]
const DEFAULT_OPTIONS: DefaultOptionTypes = {
[BOOLEAN_FIELD_TYPE]: BOOLEAN_DEFAULT_OPTIONS,
}
interface CustomDefaultValueTypes {
[key: string]: string
}
interface DefaultOptionTypes {
[key: string]: SelectItem[]
}
type FieldProps<T extends FieldValues = FieldValues> = {
fieldType: string
control: Control<T>
id: string
name: FieldPath<T> | string
label: string
fullWidth?: boolean
required?: boolean
validations?: ValidationFunction[]
getAddressFromDomain?: (name: string) => Promise<string>
networkPrefix?: string
showErrorsInTheLabel?: boolean
shouldUnregister?: boolean
options?: SelectItem[]
}
const Field = <T extends FieldValues = FieldValues>({
fieldType,
control,
name,
shouldUnregister = true,
options,
required = true,
validations,
...props
}: FieldProps<T>) => {
const FieldComponent = getFieldComponent(fieldType)
return (
<Controller
name={name as FieldPath<T>}
control={control}
defaultValue={(CUSTOM_DEFAULT_VALUES[fieldType] || '') as PathValue<T, FieldPath<T>>}
shouldUnregister={shouldUnregister}
rules={{
required: {
value: required,
message: 'Required',
},
validate: validateField(fieldType, validations),
}}
render={({ field, fieldState }) => (
<FieldComponent
name={field.name}
onChange={field.onChange}
onBlur={field.onBlur}
value={field.value}
options={options || DEFAULT_OPTIONS[fieldType]}
error={fieldState.error?.message}
required={required}
{...props}
/>
)}
/>
)
}
export default Field
interface FieldComponentBaseProps {
name: string
value: string
onChange: (...event: unknown[]) => void
onBlur?: () => void
label: string
error?: string
required?: boolean
options?: SelectItem[]
id: string
fullWidth?: boolean
getAddressFromDomain?: (name: string) => Promise<string>
networkPrefix?: string
showErrorsInTheLabel?: boolean
}
const getFieldComponent = (fieldType: string): React.FC<FieldComponentBaseProps> => {
if (isAddressFieldType(fieldType)) {
return AddressContractField as unknown as React.FC<FieldComponentBaseProps>
}
if (isBooleanFieldType(fieldType)) {
return SelectContractField as unknown as React.FC<FieldComponentBaseProps>
}
if (fieldType === CONTRACT_METHOD_FIELD_TYPE) {
return SelectContractField as unknown as React.FC<FieldComponentBaseProps>
}
if (fieldType === CUSTOM_TRANSACTION_DATA_FIELD_TYPE) {
return TextareaContractField as unknown as React.FC<FieldComponentBaseProps>
}
return TextContractField as unknown as React.FC<FieldComponentBaseProps>
}
@@ -0,0 +1,191 @@
import { useState, useCallback, ClipboardEvent } from 'react'
import styled from 'styled-components'
import IconButton from '@mui/material/IconButton'
import { Box, Button, Tooltip } from '@mui/material'
import useModal from '../../../hooks/useModal/useModal'
import { Icon, IconTypes } from '../../Icon'
import Text from '../../Text'
import GenericModal from '../../GenericModal'
import TextFieldInput from './TextFieldInput'
const DEFAULT_ROWS = 4
type Props = {
id: string
name: string
label: string
value: string
onChange: (value: string) => void
}
const JsonField = ({ id, name, label, value, onChange }: Props) => {
const { open: showReplaceModal, toggleModal } = useModal()
const [tempAbi, setTempAbi] = useState(value)
const [isPrettified, setIsPrettified] = useState(false)
const hasError = isValidJSON(value) ? undefined : 'Invalid JSON value'
const toggleFormatJSON = useCallback(() => {
if (!value) {
return
}
try {
onChange(JSON.stringify(JSON.parse(value), null, isPrettified ? 0 : 2))
setIsPrettified(!isPrettified)
} catch (e) {
console.error(e)
onChange(value)
}
}, [onChange, value, isPrettified])
const changeAbi = useCallback(() => {
onChange(tempAbi)
setIsPrettified(false)
toggleModal()
}, [tempAbi, onChange, toggleModal])
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLInputElement>) => {
event.preventDefault()
event.stopPropagation()
const clipboardData = event.clipboardData
const pastedData = clipboardData?.getData('Text') || ''
if (value && pastedData) {
setTempAbi(pastedData)
toggleModal()
} else {
onChange(pastedData)
}
},
[onChange, toggleModal, value],
)
return (
<>
<JSONFieldContainer>
<StyledTextField
id={id}
name={name}
label={label}
multiline
value={value}
minRows={DEFAULT_ROWS}
maxRows={DEFAULT_ROWS * 4}
fullWidth
hiddenLabel={false}
onPaste={handlePaste}
onChange={(event) => {
onChange(event.target.value)
}}
spellCheck={false}
showErrorsInTheLabel={false}
error={hasError}
/>
<IconContainer error={!!hasError}>
{!isPrettified && (
<IconContainerButton
error={!!hasError}
tooltipLabel="Prettify JSON"
iconType="code"
onClick={toggleFormatJSON}
/>
)}
{isPrettified && (
<IconContainerButton
error={!!hasError}
tooltipLabel="Stringify JSON"
iconType="cross"
onClick={toggleFormatJSON}
/>
)}
</IconContainer>
</JSONFieldContainer>
{showReplaceModal && (
<GenericModal
body={
<Box display="flex" alignItems="center" justifyContent="space-between" width="100%">
<Text variant="body1">Do you want to replace the current ABI?</Text>
</Box>
}
onClose={toggleModal}
title="Replace ABI"
footer={
<Box display="flex" alignItems="center" justifyContent="space-between" width="100%">
<Button variant="outlined" onClick={toggleModal}>
Cancel
</Button>
<Button variant="contained" onClick={changeAbi}>
Accept
</Button>
</Box>
}
/>
)}
</>
)
}
const isValidJSON = (value: string | undefined) => {
if (value) {
try {
JSON.parse(value)
} catch {
return false
}
}
return true
}
const IconContainerButton = ({
tooltipLabel,
iconType,
onClick,
error,
}: {
tooltipLabel: string
iconType: IconTypes
onClick: () => void
error: boolean
}) => (
<Tooltip title={tooltipLabel}>
<StyledButton size="small" color="primary" onClick={onClick}>
<Icon size="sm" color={error ? 'error' : 'primary'} type={iconType} />
</StyledButton>
</Tooltip>
)
const JSONFieldContainer = styled.div`
position: relative;
`
const IconContainer = styled.div<{ error: boolean }>`
position: absolute;
top: -10px;
right: 15px;
border: 1px solid ${({ theme, error }) => (error ? theme.palette.error.main : theme.palette.primary.main)};
border-radius: 50%;
background-color: ${({ theme }) => theme.palette.code.main};
`
const StyledTextField = styled(TextFieldInput)`
&& {
textarea {
font-family: monospace;
font-size: 12px;
&.MuiInputBase-input {
padding: 0;
}
}
}
`
const StyledButton = styled(IconButton)`
margin: 0 5px;
`
export default JsonField
@@ -0,0 +1,56 @@
import Autocomplete from '@mui/material/Autocomplete'
import { type SyntheticEvent, useCallback, useMemo } from 'react'
import TextFieldInput from './TextFieldInput'
interface SelectItem {
id: string
label: string
iconUrl?: string
subLabel?: string
}
type SelectContractFieldTypes = {
options: SelectItem[]
onChange: (id: string) => void
value: string
label: string
name: string
id: string
}
const SelectContractField = ({ value, onChange, options, label, name, id }: SelectContractFieldTypes) => {
const selectedValue = useMemo(() => options.find((opt) => opt.id === value), [options, value])
const onValueChange = useCallback(
(e: SyntheticEvent, value: SelectItem | null) => {
if (value) {
onChange(value.id)
}
},
[onChange],
)
return (
<Autocomplete
disablePortal
id={id}
options={options}
value={selectedValue}
onChange={onValueChange}
disabled={options.length === 1}
renderInput={(params) => (
<TextFieldInput
{...params}
label={label}
name={name}
InputProps={{
...params.InputProps,
id: `${id}-input`,
}}
/>
)}
/>
)
}
export default SelectContractField
@@ -0,0 +1,27 @@
import styled from 'styled-components'
import TextFieldInput, { TextFieldInputProps } from './TextFieldInput'
type TextContractFieldTypes = TextFieldInputProps & {
networkPrefix?: undefined | string
getAddressFromDomain?: () => void
}
const TextContractField = ({
networkPrefix: _networkPrefix,
getAddressFromDomain: _getAddressFromDomain,
...props
}: TextContractFieldTypes) => {
return <StyledTextField {...props} hiddenLabel={false} />
}
export default TextContractField
const StyledTextField = styled(TextFieldInput)`
&& {
textarea {
&.MuiInputBase-input {
padding: 0;
}
}
}
`
@@ -0,0 +1,57 @@
import React, { ReactElement } from 'react'
import TextFieldMui, { TextFieldProps } from '@mui/material/TextField'
import styled from 'styled-components'
import { errorStyles, inputLabelStyles, inputStyles } from './styles'
export type TextFieldInputProps = {
id?: string
name: string
label: string
error?: string
helperText?: string | undefined
hiddenLabel?: boolean | undefined
showErrorsInTheLabel?: boolean | undefined
} & Omit<TextFieldProps, 'error'>
function TextFieldInput({
id,
name,
label,
error = '',
helperText,
value,
hiddenLabel,
showErrorsInTheLabel,
...rest
}: TextFieldInputProps): ReactElement {
const hasError = !!error
return (
<TextField
id={id || name}
name={name}
label={showErrorsInTheLabel && hasError ? error : label}
value={value}
helperText={!showErrorsInTheLabel && hasError ? error : helperText}
error={hasError}
color="primary"
variant="outlined"
hiddenLabel={hiddenLabel}
InputLabelProps={{
...rest.InputLabelProps,
shrink: hiddenLabel || undefined,
}}
{...rest}
/>
)
}
const TextField = styled((props: TextFieldProps) => <TextFieldMui {...props} />)<TextFieldProps>`
&& {
${inputLabelStyles}
${inputStyles}
${errorStyles}
}
`
export default TextFieldInput

Some files were not shown because too many files have changed in this diff Show More