Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 37 additions & 102 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,102 +1,37 @@
# fix: wire error/help text to inputs via aria-describedby on EventsGrid and FormControl

Closes #534

---

## Summary

This PR improves accessibility by properly wiring error and help/description text to form controls and buttons via `aria-describedby`, in compliance with **WCAG 2.1 AA** success criteria 3.3.1 (Error Identification), 3.3.2 (Labels or Instructions), and 4.1.2 (Name, Role, Value).

## Changes

### 1. `components/events/events-grid.tsx` — Error state `aria-describedby`

- Generates a stable `errorMessageId` via `React.useId()` at the **component top level** (fixed Rules of Hooks violation — hook was originally inside a conditional block)
- Assigns `id={errorMessageId}` to the error message `<p>` element
- Links the "Try again" button to the error message via `aria-describedby={errorMessageId}`, ensuring screen readers announce the error context when the button receives focus

**Before:**
```tsx
<p className="text-sm text-muted-foreground max-w-sm mb-4">
{error}
</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Try again
</Button>
```

**After:**
```tsx
<p id={errorMessageId} className="text-sm text-muted-foreground max-w-sm mb-4">
{error}
</p>
<Button variant="outline" onClick={() => window.location.reload()} aria-describedby={errorMessageId}>
Try again
</Button>
```

### 2. `components/ui/form.tsx` — `FormControl` `aria-describedby` refactor

Replaced the ternary expression with a cleaner, more robust array-based pattern that avoids trailing whitespace and empty-string edge cases.

**Before:**
```tsx
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
```

**After:**
```tsx
aria-describedby={
[formDescriptionId, error ? formMessageId : null]
.filter(Boolean)
.join(" ")
}
```

### 3. Tests

- **`components/events/__tests__/events-grid.test.tsx`** — Added 2 tests:
- Verifies `aria-describedby` on the retry button resolves to an element containing the error text
- Verifies the error message ID is stable across re-renders (ensures `useId()` works correctly)

- **`components/ui/__tests__/form.test.tsx`** (new file) — Added 7 tests:
- Includes form description ID when there is no error
- Includes both description and message IDs when there is an error
- No empty/extra spaces in `aria-describedby`
- Sets `aria-invalid` to `true` when there is an error
- Does not set `aria-invalid` when there is no error
- Only includes description ID when no `FormDescription` is rendered
- `aria-describedby` IDs are stable across re-renders

### 4. Lint fix

- Added `displayName` to the `next/link` mock in EventsGrid tests to resolve `react/display-name` lint warning

## Verification

| Check | Status |
|-------|--------|
| ESLint | ✅ Clean on all changed files |
| Form tests (7) | ✅ All passing |
| EventsGrid tests (2 new) | ✅ All passing |
| TypeScript | ✅ No type errors on changed files |

## Files Changed

```
M components/events/__tests__/events-grid.test.tsx (+35 lines)
M components/events/events-grid.tsx (+8 lines)
M components/ui/form.tsx (+3/-3 lines)
A components/ui/__tests__/form.test.tsx (+209 lines)
```

## Accessibility Impact

- **WCAG 2.1 AA SC 3.3.1 (Error Identification)**: Error messages are now programmatically linked to the corresponding retry action
- **WCAG 2.1 AA SC 3.3.2 (Labels or Instructions)**: Form field descriptions are correctly associated with inputs
- **WCAG 2.1 AA SC 4.1.2 (Name, Role, Value)**: `aria-invalid` is properly toggled alongside `aria-describedby` for error states
Title: Prevent claim retries from duplicating intent

Summary:
- Add client-side intent deduplication to prevent duplicate transaction submissions when users retry claims.
- Persist minimal intent state in `localStorage` with a 24h TTL to allow retries to reuse signed XDR or detected submission hashes.
- Integrate intent handling into `useTransaction` to ensure deterministic behavior across retries and partial failures.

Files changed:
- `lib/transaction/intent.ts` - intent store (get/upsert/remove, computeXdrHash)
- `hooks/useTransaction.hook.ts` - integrate intent deduplication and locking
- `lib/transaction/__tests__/intent.test.ts` - intent store tests
- `hooks/__tests__/useTransaction.test.tsx` - focused transaction flow tests

Behavior and invariants:
- Intent key: `walletAddress:sha256(builtXdr)` ensures same wallet + same built XDR map to same intent.
- If an intent has `submissionHash`, retry polls for confirmation instead of re-submitting.
- If an intent has `signedXdr` but not submitted, retry will re-submit stored `signedXdr` (avoids re-signing in many cases).
- Signed XDRs are removed shortly after successful confirmation to reduce exposure.

Security considerations:
- Signed XDRs are persisted temporarily in `localStorage`. If this is unacceptable, switch to storing only the `xdrHash` and require re-signing on retry.
- Avoid storing private keys or secrets; only XDR strings are stored.

Testing:
- Unit tests cover intent store operations and transaction flows for sign-submit-confirm and retry scenarios.
- Run tests locally with `pnpm test`.

Migration / compatibility:
- No server changes or DB migrations required.
- Public API to `useTransaction.executeTransaction(buildXdr)` unchanged.

Observability:
- Intents are stored with timestamps and status; support can inspect localStorage under `predictify:intents:v1` for debugging.

Next steps (optional):
- Consider encrypting signed XDR in localStorage or reducing persistence TTL.
- Add telemetry/metrics when intents transition to `submitted` and `success`.
2 changes: 1 addition & 1 deletion app/(dashboard)/activity-timeline-demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ export default function MyPage() {
</tr>
<tr>
<td className="py-3 px-4 text-gray-700">onLoadMore</td>
<td className="py-3 px-4 text-gray-700">{"() => void"}</td>
<td className="py-3 px-4 text-gray-700">{'() => void'}</td>
<td className="py-3 px-4 text-gray-500">undefined</td>
<td className="py-3 px-4 text-gray-600">Callback when user clicks load more</td>
</tr>
Expand Down
45 changes: 21 additions & 24 deletions app/(dashboard)/disputes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react"
import { AlertTriangle, CheckCircle, Filter, Search } from "lucide-react"
import { DisputePanel } from "@/components/disputes/DisputePanel"
import { DisputeEvidencePreview } from "@/components/disputes/DisputeEvidencePreview"
import { mockDisputesByState } from "@/components/disputes/mock-data"
import type { DisputeData, DisputeState } from "@/types/disputes"
import { Button } from "@/components/ui/button"
Expand All @@ -25,21 +26,21 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { ExternalLink } from "@/components/ExternalLink"

// Type for local disputes mock data
interface LocalDispute {
id: string
eventId: string
eventTitle: string
category: string
submittedBy: string
submittedDate: string
reason: string
status: string
priority: string
evidence: string
}
type DisputePriority = "high" | "medium" | "low";

type PendingDispute = {
id: string;
eventId: string;
eventTitle: string;
category: string;
submittedBy: string;
submittedDate: string;
reason: string;
status: string;
priority: DisputePriority;
evidence: string;
};

// Mock data for disputes
const disputes = [
Expand Down Expand Up @@ -123,7 +124,7 @@ export default function DisputesPage() {
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [priorityFilter, setPriorityFilter] = useState("all")
const [selectedDispute, setSelectedDispute] = useState<LocalDispute | null>(null)
const [selectedDispute, setSelectedDispute] = useState<PendingDispute | null>(null)
const [resolution, setResolution] = useState("")
const [resolutionNotes, setResolutionNotes] = useState("")
const [selectedDisputeData, setSelectedDisputeData] = useState<DisputeData>(mockDisputesByState.none)
Expand All @@ -141,7 +142,7 @@ export default function DisputesPage() {
return matchesSearch && matchesStatus && matchesPriority
})

const getPriorityBadge = (priority: string) => {
const getPriorityBadge = (priority: DisputePriority | string) => {
switch (priority) {
case "high":
return <Badge className="bg-red-500">High</Badge>
Expand All @@ -155,9 +156,11 @@ export default function DisputesPage() {
}

const handleResolve = () => {
if (!selectedDispute) return;

// In a real app, you would send this data to your API
console.log({
disputeId: selectedDispute?.id,
disputeId: selectedDispute.id,
resolution,
notes: resolutionNotes,
})
Expand Down Expand Up @@ -275,13 +278,7 @@ export default function DisputesPage() {

<div className="space-y-2">
<Label>Evidence</Label>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<ExternalLink href={selectedDispute.evidence}>
View Evidence
</ExternalLink>
</Button>
</div>
<DisputeEvidencePreview evidence={selectedDispute.evidence} />
</div>

<div className="space-y-2">
Expand Down
Loading
Loading