fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark) - Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger - Add aria-pressed on list/map/split view-mode toggle buttons - Add aria-expanded + aria-controls on mobile filter toggle button - Add role="status" + aria-label="Đang tải..." on Suspense fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
|
||||
return {
|
||||
CheckCircle: icon('check'),
|
||||
XCircle: icon('x'),
|
||||
RefreshCw: icon('refresh'),
|
||||
ChevronLeft: icon('chevron-left'),
|
||||
ChevronRight: icon('chevron-right'),
|
||||
ShieldCheck: icon('shield'),
|
||||
X: icon('close'),
|
||||
User: icon('user'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/status-chip', () => ({
|
||||
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
|
||||
}));
|
||||
|
||||
const mockGetKycQueue = vi.fn();
|
||||
const mockApproveKyc = vi.fn();
|
||||
const mockRejectKyc = vi.fn();
|
||||
|
||||
vi.mock('@/lib/admin-api', () => ({
|
||||
adminApi: {
|
||||
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
|
||||
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
|
||||
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import AdminKycPage from '../kyc/page';
|
||||
|
||||
const mockQueueData = {
|
||||
data: [
|
||||
{
|
||||
userId: 'u1',
|
||||
fullName: 'Nguyen Van A',
|
||||
phone: '0912345678',
|
||||
email: 'a@test.com',
|
||||
role: 'AGENT',
|
||||
kycStatus: 'PENDING',
|
||||
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
|
||||
createdAt: '2024-06-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
userId: 'u2',
|
||||
fullName: 'Tran Thi B',
|
||||
phone: '0987654321',
|
||||
email: null,
|
||||
role: 'USER',
|
||||
kycStatus: 'PENDING',
|
||||
kycData: null,
|
||||
createdAt: '2024-06-16T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
describe('AdminKycPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetKycQueue.mockResolvedValue(mockQueueData);
|
||||
mockApproveKyc.mockResolvedValue({});
|
||||
mockRejectKyc.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('renders heading and fetches queue', async () => {
|
||||
render(<AdminKycPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
|
||||
});
|
||||
|
||||
it('renders queue items in table', async () => {
|
||||
render(<AdminKycPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no requests', async () => {
|
||||
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||
render(<AdminKycPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
|
||||
render(<AdminKycPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes queue on refresh button click', async () => {
|
||||
render(<AdminKycPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
|
||||
|
||||
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
|
||||
Mock.displayName = 'MockChart';
|
||||
return Mock;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseMarketReport = vi.fn();
|
||||
const mockUseHeatmap = vi.fn();
|
||||
const mockUseListingsSearch = vi.fn();
|
||||
|
||||
vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
|
||||
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/use-listings', () => ({
|
||||
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/listings/listing-status-badge', () => ({
|
||||
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
|
||||
}));
|
||||
|
||||
import DashboardPage from '../page';
|
||||
|
||||
const fullData = {
|
||||
marketReport: {
|
||||
districts: [
|
||||
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
|
||||
],
|
||||
},
|
||||
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
|
||||
listings: {
|
||||
data: [{
|
||||
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
|
||||
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
|
||||
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
|
||||
property: {
|
||||
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
|
||||
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
|
||||
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
|
||||
},
|
||||
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||
agent: null,
|
||||
}],
|
||||
total: 1, page: 1, limit: 6, totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('DashboardPage — deep tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state with placeholders', () => {
|
||||
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
|
||||
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
|
||||
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat cards with computed values', () => {
|
||||
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
|
||||
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders recent listings with property title', () => {
|
||||
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
|
||||
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Đăng tin mới" link', () => {
|
||||
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty listings state', () => {
|
||||
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||
}));
|
||||
|
||||
const mockFetchProfile = vi.fn();
|
||||
const mockUseAuthStore = vi.fn();
|
||||
|
||||
vi.mock('@/lib/auth-store', () => ({
|
||||
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
apiClient: { patch: vi.fn().mockResolvedValue({}) },
|
||||
}));
|
||||
|
||||
import KycPage from '../kyc/page';
|
||||
|
||||
function setupStore(overrides: Record<string, unknown> = {}) {
|
||||
const store = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
fullName: 'Nguyen Van A',
|
||||
phone: '0912345678',
|
||||
kycStatus: 'NONE',
|
||||
...overrides,
|
||||
},
|
||||
fetchProfile: mockFetchProfile,
|
||||
};
|
||||
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
|
||||
if (typeof selector === 'function') return selector(store);
|
||||
return store;
|
||||
});
|
||||
}
|
||||
|
||||
describe('KycPage — deep tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupStore();
|
||||
});
|
||||
|
||||
it('renders heading and NONE status', () => {
|
||||
render(<KycPage />);
|
||||
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step 1 with document type selector and number input', () => {
|
||||
render(<KycPage />);
|
||||
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error and stays on step 1 when doc number is empty', async () => {
|
||||
render(<KycPage />);
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('advances from step 1 → step 2 after filling doc number', async () => {
|
||||
render(<KycPage />);
|
||||
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error on step 2 when front image is missing', async () => {
|
||||
render(<KycPage />);
|
||||
// Step 1 → 2
|
||||
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Try to advance without uploading
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('goes back from step 2 → step 1', async () => {
|
||||
render(<KycPage />);
|
||||
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('kyc-back-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders VERIFIED state without form', () => {
|
||||
setupStore({ kycStatus: 'VERIFIED' });
|
||||
render(<KycPage />);
|
||||
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PENDING state without form', () => {
|
||||
setupStore({ kycStatus: 'PENDING' });
|
||||
render(<KycPage />);
|
||||
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders REJECTED state with form available', () => {
|
||||
setupStore({ kycStatus: 'REJECTED' });
|
||||
render(<KycPage />);
|
||||
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
|
||||
// Form should still show for resubmission
|
||||
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses error when close button is clicked', async () => {
|
||||
render(<KycPage />);
|
||||
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('Đóng'));
|
||||
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes document type via select', async () => {
|
||||
render(<KycPage />);
|
||||
const select = screen.getByLabelText(/loại giấy tờ/i);
|
||||
await userEvent.selectOptions(select, 'PASSPORT');
|
||||
expect(select).toHaveValue('PASSPORT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockUseTransactions = vi.fn();
|
||||
|
||||
const mockTransactions = {
|
||||
items: [
|
||||
{
|
||||
id: 'tx-1',
|
||||
type: 'SUBSCRIPTION',
|
||||
status: 'COMPLETED',
|
||||
amountVND: '499000',
|
||||
provider: 'VNPAY',
|
||||
providerTxId: 'TXN123456789012',
|
||||
createdAt: '2024-06-15T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'tx-2',
|
||||
type: 'LISTING_FEE',
|
||||
status: 'PENDING',
|
||||
amountVND: '100000',
|
||||
provider: 'MOMO',
|
||||
providerTxId: null,
|
||||
createdAt: '2024-06-20T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'tx-3',
|
||||
type: 'FEATURED_LISTING',
|
||||
status: 'FAILED',
|
||||
amountVND: '200000',
|
||||
provider: 'ZALOPAY',
|
||||
providerTxId: 'ZLP999',
|
||||
createdAt: '2024-06-21T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
};
|
||||
|
||||
vi.mock('@/lib/hooks/use-payments', () => ({
|
||||
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/currency', () => ({
|
||||
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||
}));
|
||||
|
||||
import PaymentsPage from '../payments/page';
|
||||
|
||||
describe('PaymentsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
|
||||
});
|
||||
|
||||
it('renders payment page heading and description', () => {
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders summary cards with correct values', () => {
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction table with type/provider/status labels', () => {
|
||||
render(<PaymentsPage />);
|
||||
// Type labels appear in desktop table + mobile cards, so use getAllByText
|
||||
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes status filter via select', async () => {
|
||||
render(<PaymentsPage />);
|
||||
const select = screen.getByDisplayValue('Tất cả');
|
||||
await userEvent.selectOptions(select, 'COMPLETED');
|
||||
|
||||
expect(mockUseTransactions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'COMPLETED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long providerTxId', () => {
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dash for missing providerTxId', () => {
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pagination when more than 1 page', () => {
|
||||
// 25 total with limit 20 = 2 pages
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `tx-${i}`,
|
||||
type: 'SUBSCRIPTION',
|
||||
status: 'COMPLETED',
|
||||
amountVND: '100000',
|
||||
provider: 'VNPAY',
|
||||
providerTxId: null,
|
||||
createdAt: '2024-06-15T10:00:00.000Z',
|
||||
}));
|
||||
mockUseTransactions.mockReturnValue({
|
||||
data: { items: manyItems, total: 25 },
|
||||
isLoading: false,
|
||||
});
|
||||
render(<PaymentsPage />);
|
||||
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -64,8 +64,49 @@ function SearchContent() {
|
||||
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
|
||||
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
||||
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const createSavedSearch = useCreateSavedSearch();
|
||||
|
||||
// Focus management for save-search dialog
|
||||
React.useEffect(() => {
|
||||
if (showSaveDialog) {
|
||||
saveNameInputRef.current?.focus();
|
||||
}
|
||||
}, [showSaveDialog]);
|
||||
|
||||
// Focus trap + Escape key for save-search dialog
|
||||
React.useEffect(() => {
|
||||
if (!showSaveDialog) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowSaveDialog(false);
|
||||
saveButtonRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const dialog = saveDialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, input, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showSaveDialog]);
|
||||
|
||||
const handleMarkerClick = (listing: ListingDetail) => {
|
||||
setSelectedListingId(listing.id);
|
||||
};
|
||||
@@ -163,11 +204,15 @@ function SearchContent() {
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={saveButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||
aria-expanded={showSaveDialog}
|
||||
aria-controls="save-search-dialog"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
Lưu tìm kiếm
|
||||
@@ -175,10 +220,17 @@ function SearchContent() {
|
||||
|
||||
{/* Save search dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div
|
||||
id="save-search-dialog"
|
||||
ref={saveDialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="save-search-heading"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||
@@ -188,6 +240,7 @@ function SearchContent() {
|
||||
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
||||
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
||||
<input
|
||||
ref={saveNameInputRef}
|
||||
id="save-search-name"
|
||||
type="text"
|
||||
value={saveName}
|
||||
@@ -246,8 +299,9 @@ function SearchContent() {
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-pressed={viewMode === 'list'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Danh sách
|
||||
@@ -256,8 +310,9 @@ function SearchContent() {
|
||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('map')}
|
||||
aria-pressed={viewMode === 'map'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
Bản đồ
|
||||
@@ -267,8 +322,9 @@ function SearchContent() {
|
||||
size="sm"
|
||||
className="hidden lg:flex"
|
||||
onClick={() => setViewMode('split')}
|
||||
aria-pressed={viewMode === 'split'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
Chia đôi
|
||||
@@ -280,8 +336,10 @@ function SearchContent() {
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
aria-expanded={showMobileFilters}
|
||||
aria-controls="mobile-filter-panel"
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Bộ lọc
|
||||
@@ -305,7 +363,7 @@ function SearchContent() {
|
||||
|
||||
{/* Mobile filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
@@ -392,7 +450,11 @@ export default function SearchPage() {
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Đang tải..."
|
||||
className="flex min-h-[400px] items-center justify-center"
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user