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
88 changes: 88 additions & 0 deletions docs/filters-collapse-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Polish FiltersSidebar with Collapse Persistence

## Summary

This PR implements collapsible filter sections in `FiltersSidebar` with localStorage persistence to remember collapsed states across page visits. Users can now collapse/expand each filter group (Categories, Price range, Popularity) independently, and their preferences are saved for future sessions.

## Changes

### `src/components/FiltersSidebar.tsx`
- Refactored filter sections into a reusable `FilterGroup` component with collapsible behavior
- Each filter group now has an expandable/collapsible header with a chevron indicator
- Uses `usePersistedState` hook to persist collapsed state in localStorage
- Implements proper ARIA attributes (`aria-expanded`, `aria-controls`) for accessibility
- Uses `hidden` attribute to hide collapsed content from screen readers

### `src/components/icons/ChevronIcon.tsx` (new)
- New icon component for the expand/collapse chevron indicator
- Follows the existing icon pattern in the codebase (size variants, accessibility)

### `src/components/icons/index.tsx`
- Added export for the new `ChevronIcon` component

### `src/index.css`
- Added styles for `.filter-group__header`, `.filter-group__chevron`, and `.filter-group__panel`
- Includes hover and focus-visible states matching design-token patterns
- Reduced motion support via `prefers-reduced-motion` media query
- Responsive adjustments for smaller viewports (max-width: 640px)

### `src/state/uiPrefs.ts`
- Added `isSectionCollapsed()`, `toggleSectionCollapsed()`, and `setSectionCollapsed()` functions for collapsed section state management

### Test Files
- `src/components/FiltersSidebar.test.tsx` - New comprehensive tests for collapse functionality
- `src/state/uiPrefs.test.ts` - Tests for the uiPrefs collapse state functions

## Accessibility (WCAG 2.1 AA)

- `aria-expanded` indicates the current state of each filter group
- `aria-controls` associates headers with their content panels
- `hidden` attribute removes collapsed content from assistive technology
- Focus styles use `var(--accent)` with proper contrast against both light and dark backgrounds
- `prefers-reduced-motion` support for users who request reduced motion

## Responsive Design

The filter headers adapt to smaller screens:
- Reduced padding (10px 14px) on screens under 640px
- Smaller font size (0.9rem) on mobile viewports
- All breakpoints maintain proper touch target sizes (minimum 44px height)

## API/Visible Changes

### Component API (no breaking changes)
`FiltersSidebar` maintains the same external props interface - this is a purely visual enhancement.

### localStorage Keys
- `callora.filters.categories.collapsed` - Categories section state (boolean)
- `callora.filters.price.collapsed` - Price range section state (boolean)
- `callora.filters.popularity.collapsed` - Popularity section state (boolean)

## Test Output

```
✓ src/components/FiltersSidebar.test.tsx (16 tests) 966ms
✓ src/state/uiPrefs.test.ts (9 tests) 11ms
```

All tests pass including:
- Default expanded state verification
- localStorage persistence on toggle
- State restoration on re-render
- aria-expanded and aria-controls attribute correctness
- Independent collapse/expand of each section
- Chevron rotation animation
- Price validation error handling

## Notes

The existing tests in the repository have pre-existing issues unrelated to this change:
- `FiltersBottomSheet.test.tsx` - missing `window.matchMedia` mock in jsdom
- `Tabs.test.tsx` - same matchMedia issue
- `ApiDetailPage.tsx`, `ApiUsage.tsx`, `ApiCard.tsx` - JSX syntax errors in existing code

These do not affect the functionality of the changes in this PR.

PR: https://github.com/CalloraOrg/Callora-Frontend/pull/337

closes #254
66 changes: 66 additions & 0 deletions docs/snapshot-url-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Add 'Snapshot endpoint' export to share UI state

## Summary

This PR implements a shareable URL feature that captures the current endpoint selection and parameters. Users can generate a URL snapshot of their configured endpoint with parameters and share it with others, who will see the same endpoint and parameters pre-loaded.

## Changes

### `src/utils/snapshotUrl.ts` (new)
- `generateSnapshotUrl(basePath, snapshot)` - Creates a URL with endpoint ID and base64-encoded params
- `parseSnapshotUrl(search)` - Parses URL to extract endpoint and params, returns null if invalid
- `copySnapshotUrl(basePath, snapshot)` - Copies snapshot URL to clipboard for sharing

### `src/ApiUsage.tsx`
- Added `snapshotted` state to track copy feedback
- Added `handleShareSnapshot()` function to generate and copy snapshot URL
- Added `useEffect` to restore endpoint params from snapshot URL on mount
- Added "Share Snapshot" button next to "Make Test Call" button
- Imported `LinkIcon` for the share button

### `src/components/icons/LinkIcon.tsx` (new)
- New link/chain icon component for sharing functionality
- Follows existing icon patterns (size variants, accessibility)

### `src/components/icons/index.tsx`
- Added export for `LinkIcon`

### `src/index.css`
- Added `.share-snapshot-button` styles with proper spacing

### `src/utils/snapshotUrl.test.ts` (new)
- 11 tests covering URL generation, parsing, and clipboard copying
- Tests edge cases like special characters, malformed params, empty values

## API/Visible Changes

### URL Parameters
- `endpoint` - The selected endpoint ID (e.g., "endpoint-1", "endpoint-2")
- `params` - Base64-encoded JSON of request parameters

### Example URL
```
/usage?endpoint=endpoint-2&params=eyJhYm91dCI6MTAwLCJjdXJrenVjdXIiOiJVU0QifQ%3D%3D
```

## Accessibility (WCAG 2.1 AA)
- Share button has `aria-label` for screen readers
- Icon is marked `aria-hidden` when button has accessible label
- Focus styles follow existing design tokens

## Test Output

```
✓ src/utils/snapshotUrl.test.ts (11 tests) 36ms
```

All tests pass including:
- URL generation with/without params
- Base64 encoding/decoding with Unicode support
- Graceful handling of malformed URLs
- Clipboard copy success/failure

closes #252

## PR Link
https://github.com/CalloraOrg/Callora-Frontend/pull/339
44 changes: 44 additions & 0 deletions src/ApiUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { formatPrice } from './utils/format';
import RequestBodyEditor from './components/RequestBodyEditor';
import type { JsonSchema } from './components/RequestBodyEditor';
import CallHistoryRow from './components/CallHistoryRow';
import { generateSnapshotUrl, parseSnapshotUrl, copySnapshotUrl } from './utils/snapshotUrl';
import { LinkIcon } from './components/icons';

type ApiEndpoint = {
id: string;
Expand Down Expand Up @@ -201,6 +203,38 @@ export default function ApiUsage() {
const [selectedLanguage, setSelectedLanguage] = useState<'javascript' | 'python' | 'curl'>('javascript');
const [selectedRange, setSelectedRange] = useState<DateRange>({ preset: '24h' });
const [expandedCall, setExpandedCall] = useState<string | null>(null);
const [snapshotted, setSnapshotted] = useState(false);

// Restore endpoint params from snapshot URL on mount
useEffect(() => {
const snapshot = parseSnapshotUrl(window.location.search);
if (snapshot?.endpointId) {
const endpoint = MOCK_ENDPOINTS.find(ep => ep.id === snapshot.endpointId);
if (endpoint) {
setSelectedEndpoint(endpoint);
if (snapshot.params) {
setRequestParams(JSON.stringify(snapshot.params, null, 2));
}
}
}
}, []);

const handleShareSnapshot = async () => {
let parsedParams: Record<string, unknown> | null = null;
try {
parsedParams = JSON.parse(requestParams);
} catch {
// Invalid JSON, use null
}
const success = await copySnapshotUrl(window.location.pathname, {
endpointId: selectedEndpoint.id,
params: parsedParams,
});
if (success) {
setSnapshotted(true);
setTimeout(() => setSnapshotted(false), 2000);
}
};

// Filter call history based on selected date range
const filterCallsByRange = (calls: CallRecord[]): CallRecord[] => {
Expand Down Expand Up @@ -507,6 +541,16 @@ export default function ApiUsage() {
{isLoading && <span className="button-spinner" aria-hidden="true" />}
{isLoading ? 'Making Call...' : 'Make Test Call'}
</button>

<button
className="secondary-button share-snapshot-button"
onClick={handleShareSnapshot}
disabled={isLoading}
aria-label="Share snapshot URL"
>
<LinkIcon size={16} />
{snapshotted ? 'Copied!' : 'Share Snapshot'}
</button>
</div>

{(apiResponse || isLoading) && (
Expand Down
168 changes: 168 additions & 0 deletions src/components/FiltersSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import FiltersSidebar from "./FiltersSidebar";

const baseProps = {
selectedCategories: new Set<string>(),
toggleCategory: vi.fn(),
minPrice: null,
maxPrice: null,
setMinPrice: vi.fn(),
setMaxPrice: vi.fn(),
popularity: "any",
setPopularity: vi.fn(),
clearFilters: vi.fn(),
};

describe("FiltersSidebar", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});

afterEach(() => {
cleanup();
localStorage.clear();
});

it("renders all filter sections", () => {
render(<FiltersSidebar {...baseProps} />);
expect(screen.getByText("Categories")).toBeTruthy();
expect(screen.getByText("Price range")).toBeTruthy();
expect(screen.getByText("Popularity")).toBeTruthy();
});

it("renders category checkboxes", () => {
render(<FiltersSidebar {...baseProps} />);
expect(screen.getByLabelText("Data & Analytics")).toBeTruthy();
expect(screen.getByLabelText("Payment Processing")).toBeTruthy();
expect(screen.getByLabelText("Communication")).toBeTruthy();
expect(screen.getByLabelText("AI/ML")).toBeTruthy();
expect(screen.getByLabelText("Other")).toBeTruthy();
});

it("calls toggleCategory when checkbox is clicked", () => {
const toggleCategory = vi.fn();
render(<FiltersSidebar {...baseProps} toggleCategory={toggleCategory} />);
fireEvent.click(screen.getByLabelText("Data & Analytics"));
expect(toggleCategory).toHaveBeenCalledWith("Data & Analytics");
});

describe("collapse functionality", () => {
it("expands panel by default when no persisted state", () => {
render(<FiltersSidebar {...baseProps} />);
const categoriesPanel = screen.getByTestId("filter-panel-categories");
expect(categoriesPanel.hasAttribute("hidden")).toBe(false);
});

it("persists collapsed state on toggle", () => {
const { unmount } = render(<FiltersSidebar {...baseProps} />);

const header = screen.getByRole("button", { name: "Categories" });
fireEvent.click(header);

expect(localStorage.getItem("callora.filters.categories.collapsed")).toBe("true");
unmount();
});

it("restores collapsed state from localStorage on re-render", () => {
localStorage.setItem("callora.filters.categories.collapsed", "true");

render(<FiltersSidebar {...baseProps} />);
const categoriesPanel = screen.getByTestId("filter-panel-categories");
expect(categoriesPanel.hasAttribute("hidden")).toBe(true);
});

it("restores expanded state from localStorage when set to false", () => {
localStorage.setItem("callora.filters.popularity.collapsed", "false");

render(<FiltersSidebar {...baseProps} />);
const popularityPanel = screen.getByTestId("filter-panel-popularity");
expect(popularityPanel.hasAttribute("hidden")).toBe(false);
});

it("sets aria-expanded to true when panel is expanded", () => {
render(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
expect(header.getAttribute("aria-expanded")).toBe("true");
});

it("sets aria-expanded to false when panel is collapsed", () => {
localStorage.setItem("callora.filters.categories.collapsed", "true");

render(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
expect(header.getAttribute("aria-expanded")).toBe("false");
});

it("has correct aria-controls attribute on header", () => {
render(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
expect(header.getAttribute("aria-controls")).toBe("filter-panel-categories");
});

it("collapses all sections independently", () => {
render(<FiltersSidebar {...baseProps} />);

const priceHeader = screen.getByRole("button", { name: "Price range" });
fireEvent.click(priceHeader);

const categoriesPanel = screen.getByTestId("filter-panel-categories");
const pricePanel = screen.getByTestId("filter-panel-price");
const popularityPanel = screen.getByTestId("filter-panel-popularity");

expect(categoriesPanel.hasAttribute("hidden")).toBe(false);
expect(pricePanel.hasAttribute("hidden")).toBe(true);
expect(popularityPanel.hasAttribute("hidden")).toBe(false);
});

it("rotates chevron when collapsed", () => {
localStorage.setItem("callora.filters.categories.collapsed", "true");

render(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
const chevron = header.querySelector(".filter-group__chevron");
expect(chevron?.classList.contains("filter-group__chevron--collapsed")).toBe(true);
});
});

describe("clear filters", () => {
it("calls clearFilters when Clear filters button is clicked", () => {
const clearFilters = vi.fn();
render(<FiltersSidebar {...baseProps} clearFilters={clearFilters} />);
fireEvent.click(screen.getByText("Clear filters"));
expect(clearFilters).toHaveBeenCalledTimes(1);
});
});

describe("price validation", () => {
it("shows error when min price exceeds max price", () => {
render(
<FiltersSidebar
{...baseProps}
minPrice={100}
maxPrice={50}
/>
);
expect(screen.getByText(/Min price cannot exceed max price/i)).toBeTruthy();
});

it("does not show error when prices are valid", () => {
render(
<FiltersSidebar
{...baseProps}
minPrice={50}
maxPrice={100}
/>
);
expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull();
});

it("does not show error when both prices are null", () => {
render(<FiltersSidebar {...baseProps} />);
expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull();
});
});
});
Loading