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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

const estimateStorage = vi.fn();
vi.mock('@/lib/offline-queue/storage-quota', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/offline-queue/storage-quota')>();
return { ...actual, estimateStorage: () => estimateStorage() };
});

vi.mock('@/hooks/useOfflineQueue', () => ({
useOfflineQueue: () => ({
queueCount: 1,
failedCount: 0,
isSyncing: false,
isOnline: true,
retryFailed: vi.fn(),
}),
}));

import QueueStatusPill from './QueueStatusPill';

expect.extend(toHaveNoViolations);

describe('QueueStatusPill Accessibility', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('has no violations without a storage warning', async () => {
estimateStorage.mockResolvedValue({
usage: null,
quota: null,
ratio: null,
warning: false,
supported: true,
});
const { container } = render(<QueueStatusPill />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('has no violations with a storage warning shown', async () => {
estimateStorage.mockResolvedValue({
usage: 9 * 1024 * 1024,
quota: 10 * 1024 * 1024,
ratio: 0.9,
warning: true,
supported: true,
});
const { container, findByRole } = render(<QueueStatusPill />);
await findByRole('alert');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import QueueStatusPill from './QueueStatusPill';

/**
* QueueStatusPill composes the offline-queue status indicator with a
* storage-quota warning. In Storybook the live `useOfflineQueue` hook and the
* StorageManager API drive the rendering; the storage warning only appears when
* the browser reports usage at/over 80% of quota. See the unit tests for the
* warning-state coverage (storage is mocked there).
*/
const meta: Meta<typeof QueueStatusPill> = {
title: 'Components/Molecular/QueueStatusPill',
component: QueueStatusPill,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'QueueStatusPill component for the molecular category — offline-queue status + storage-quota warning.',
},
},
},
tags: ['autodocs'],
argTypes: {
showRetryButton: {
control: 'boolean',
description: 'Show retry button for failed messages',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
showRetryButton: true,
},
};
77 changes: 77 additions & 0 deletions src/components/molecular/QueueStatusPill/QueueStatusPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the storage-quota helper so we control the warning state.
const estimateStorage = vi.fn();
vi.mock('@/lib/offline-queue/storage-quota', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/offline-queue/storage-quota')>();
return { ...actual, estimateStorage: () => estimateStorage() };
});

// Mock the offline-queue hook the wrapped indicator uses (keep it quiet/empty
// so the indicator renders nothing and we isolate the storage-warning behavior).
vi.mock('@/hooks/useOfflineQueue', () => ({
useOfflineQueue: () => ({
queueCount: 1, // non-empty so the indicator renders (not null)
failedCount: 0,
isSyncing: false,
isOnline: true,
retryFailed: vi.fn(),
}),
}));

import QueueStatusPill from './QueueStatusPill';

describe('QueueStatusPill', () => {
beforeEach(() => {
vi.clearAllMocks();
estimateStorage.mockResolvedValue({
usage: null,
quota: null,
ratio: null,
warning: false,
supported: true,
});
});

it('renders without crashing', () => {
const { container } = render(<QueueStatusPill />);
expect(container.firstChild).toBeInTheDocument();
});

it('does NOT show a storage warning under the threshold', async () => {
render(<QueueStatusPill />);
// give the effect a tick
await waitFor(() => expect(estimateStorage).toHaveBeenCalled());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('shows a storage warning when usage is near quota', async () => {
estimateStorage.mockResolvedValue({
usage: 8.5 * 1024 * 1024,
quota: 10 * 1024 * 1024,
ratio: 0.85,
warning: true,
supported: true,
});
render(<QueueStatusPill />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/storage is almost full/i);
expect(alert).toHaveTextContent(/8\.5 MB of 10\.0 MB/);
});

it('omits the usage figure when only the ratio is known', async () => {
estimateStorage.mockResolvedValue({
usage: null,
quota: null,
ratio: 0.9,
warning: true,
supported: true,
});
render(<QueueStatusPill />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/storage is almost full/i);
expect(alert).not.toHaveTextContent(/MB of/);
});
});
75 changes: 75 additions & 0 deletions src/components/molecular/QueueStatusPill/QueueStatusPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import React, { useEffect, useState } from 'react';
import QueueStatusIndicator from '@/components/atomic/QueueStatusIndicator';
import {
estimateStorage,
formatStorageUsage,
type StorageEstimateResult,
} from '@/lib/offline-queue/storage-quota';

export interface QueueStatusPillProps {
/** Show retry button for failed messages (passed through to the indicator). */
showRetryButton?: boolean;
/** Callback when retry is clicked. */
onRetry?: () => void;
/** Additional CSS classes. */
className?: string;
}

/**
* QueueStatusPill component (#32, feature 020).
*
* Composes the atomic QueueStatusIndicator (queued / syncing / failed / offline)
* with a **storage-quota warning**: when the origin's IndexedDB usage is at/over
* 80% of quota, it surfaces a warning so the user knows the offline queue may
* stop persisting new items. Storage info degrades gracefully — browsers without
* the StorageManager API simply show no warning.
*
* @category molecular
*/
export default function QueueStatusPill({
showRetryButton = true,
onRetry,
className = '',
}: QueueStatusPillProps) {
const [storage, setStorage] = useState<StorageEstimateResult | null>(null);

useEffect(() => {
let cancelled = false;
void estimateStorage().then((result) => {
if (!cancelled) setStorage(result);
});
return () => {
cancelled = true;
};
}, []);

const showStorageWarning = storage?.warning === true;

return (
<div className={`queue-status-pill${className ? ` ${className}` : ''}`}>
<QueueStatusIndicator
showRetryButton={showRetryButton}
onRetry={onRetry}
/>

{showStorageWarning && (
<div
role="alert"
className="alert alert-warning mt-2 text-sm"
aria-live="polite"
>
<span>
Device storage is almost full
{formatStorageUsage(storage.usage, storage.quota)
? ` (${formatStorageUsage(storage.usage, storage.quota)})`
: ''}
. Queued messages may not be saved until you free up space or come
back online.
</span>
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/molecular/QueueStatusPill/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './QueueStatusPill';
export type { QueueStatusPillProps } from './QueueStatusPill';
89 changes: 89 additions & 0 deletions src/lib/offline-queue/__tests__/storage-quota.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
estimateStorage,
isStorageNearQuota,
formatStorageUsage,
STORAGE_WARNING_THRESHOLD,
} from '../storage-quota';

const originalNavigator = globalThis.navigator;

function setEstimate(impl: (() => Promise<unknown>) | undefined) {
Object.defineProperty(globalThis, 'navigator', {
value: impl ? { storage: { estimate: impl } } : {},
configurable: true,
writable: true,
});
}

afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
vi.restoreAllMocks();
});

describe('estimateStorage', () => {
it('returns unsupported when StorageManager is missing', async () => {
setEstimate(undefined);
const r = await estimateStorage();
expect(r.supported).toBe(false);
expect(r.warning).toBe(false);
expect(r.ratio).toBeNull();
});

it('computes ratio + no warning below the threshold', async () => {
setEstimate(async () => ({ usage: 5, quota: 100 }));
const r = await estimateStorage();
expect(r.supported).toBe(true);
expect(r.ratio).toBeCloseTo(0.05);
expect(r.warning).toBe(false);
});

it('flags a warning at/over the 80% threshold', async () => {
setEstimate(async () => ({ usage: 80, quota: 100 }));
const r = await estimateStorage();
expect(r.ratio).toBeCloseTo(STORAGE_WARNING_THRESHOLD);
expect(r.warning).toBe(true);
});

it('treats a zero/invalid quota as unknown (no divide-by-zero warning)', async () => {
setEstimate(async () => ({ usage: 5, quota: 0 }));
const r = await estimateStorage();
expect(r.supported).toBe(true);
expect(r.ratio).toBeNull();
expect(r.warning).toBe(false);
});

it('fails open when estimate() throws', async () => {
setEstimate(async () => {
throw new Error('denied');
});
const r = await estimateStorage();
expect(r.supported).toBe(true);
expect(r.warning).toBe(false);
});
});

describe('isStorageNearQuota', () => {
it('mirrors the warning flag', async () => {
setEstimate(async () => ({ usage: 90, quota: 100 }));
expect(await isStorageNearQuota()).toBe(true);
setEstimate(async () => ({ usage: 10, quota: 100 }));
expect(await isStorageNearQuota()).toBe(false);
});
});

describe('formatStorageUsage', () => {
it('formats MB pairs', () => {
expect(formatStorageUsage(8.5 * 1024 * 1024, 10 * 1024 * 1024)).toBe(
'8.5 MB of 10.0 MB'
);
});
it('returns null when either side is unknown', () => {
expect(formatStorageUsage(null, 100)).toBeNull();
expect(formatStorageUsage(100, null)).toBeNull();
});
});
Loading