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
38 changes: 38 additions & 0 deletions apps/web/src/components/NotificationToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useSyncExternalStore } from 'react';
import { notificationStore } from '../stores/notification.store';

/**
* Standing warnings, dismissed by hand rather than on a timer: a trust warning
* that expired unread would read as "nothing was wrong".
*/
export function NotificationToast() {
const notices = useSyncExternalStore(notificationStore.subscribe, notificationStore.getState);

if (notices.length === 0) return null;

return (
<div className="notification-toast" data-testid="notification-toast">
{notices.map((notice) => (
<div
key={notice.key}
className="notification-toast-item"
role="alert"
data-testid="notification-notice"
>
<span className="notification-toast-label" aria-hidden="true">
[WARN]
</span>
<span className="notification-toast-message">{notice.message}</span>
<button
type="button"
className="notification-toast-dismiss"
aria-label="Dismiss warning"
onClick={() => notificationStore.dismiss(notice.key)}
>
[x]
</button>
</div>
))}
</div>
);
}
90 changes: 90 additions & 0 deletions apps/web/src/components/file-browser/FileBrowser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { EngineRequestError } from '@cipherbox/client';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { EngineProvider } from '../../providers/EngineProvider';
import { ROOT_ID, fakeEngine, view } from '../../engine/testFakes';
import { FileBrowser } from './FileBrowser';

function draw(client: ReturnType<typeof fakeEngine>['client']) {
return render(
<MemoryRouter initialEntries={['/files']}>
<EngineProvider createClient={() => client}>
<Routes>
<Route path="/files/:nodeId?" element={<FileBrowser />} />
</Routes>
</EngineProvider>
</MemoryRouter>
);
}

/** Renders the browser with two rows on screen, then fails its next pull. */
async function listedThenFailed(failure: Error) {
const engine = fakeEngine();
draw(engine.client);

await act(async () => {
engine.emit({ kind: 'snapshotUpdated' });
});
await act(async () => {
engine.pulls[0].resolve(view(ROOT_ID, 'fresh', 2));
});
await waitFor(() => expect(screen.getAllByTestId('file-list-item')).toHaveLength(2));

await act(async () => {
engine.emit({ kind: 'snapshotUpdated' });
});
await act(async () => {
engine.pulls[1].reject(failure);
});
return engine;
}

describe('the vault browser', () => {
it('keeps the listing on screen when a refusal is recoverable', async () => {
await listedThenFailed(
new EngineRequestError('too many read streams are already open', 'tooManyStreams')
);

const notice = await screen.findByTestId('file-browser-notice');
expect(notice.textContent).toContain('too many read streams are already open');
// The gate is the rows, not the notice: a blanked listing must not pass.
expect(screen.getAllByTestId('file-list-item')).toHaveLength(2);
expect(screen.queryByTestId('file-browser-error')).toBeNull();
});

it('blanks the listing on a failure that will not clear', async () => {
await listedThenFailed(new EngineRequestError('no such node', 'unknownNode'));

const error = await screen.findByTestId('file-browser-error');
expect(error.textContent).toBe('no such node');
expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0);
expect(screen.queryByTestId('file-browser-notice')).toBeNull();
});

it('treats an engine code it does not recognise as fatal', async () => {
await listedThenFailed(new EngineRequestError('something new', 'someFutureCeiling'));

expect(await screen.findByTestId('file-browser-error')).toBeTruthy();
expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0);
});

it('re-drives the pull from the recoverable notice', async () => {
const engine = await listedThenFailed(
new EngineRequestError('too many read streams are already open', 'tooManyStreams')
);
await screen.findByTestId('file-browser-notice');
expect(engine.refreshes()).toBe(0);

await act(async () => {
fireEvent.click(screen.getByText('[retry]'));
});

expect(engine.refreshes()).toBe(1);
await act(async () => {
engine.pulls[2].resolve(view(ROOT_ID, 'fresh', 2));
});
await waitFor(() => expect(screen.queryByTestId('file-browser-notice')).toBeNull());
expect(screen.getAllByTestId('file-list-item')).toHaveLength(2);
});
});
22 changes: 20 additions & 2 deletions apps/web/src/components/file-browser/FileBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { isRecoverable } from '../../engine/snapshotStore';
import { useSnapshot } from '../../engine/useSnapshot';
import { useSnapshotStore } from '../../providers/EngineProvider';
import { useFolderNavigation } from '../../vault/useFolderNavigation';
import { Breadcrumbs } from './Breadcrumbs';
import { DeadLetterNotice } from './DeadLetterNotice';
Expand All @@ -11,13 +13,29 @@ export function FileBrowser() {
const { rows, folder, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } =
useFolderNavigation();
const { view } = useSnapshot();
const settled = !isLoading && error === null;
const store = useSnapshotStore();
// A ceiling refusal clears on its own, so it renders over the listing it
// interrupted; anything else is a verdict and blanks it.
const ceiling = error !== null && isRecoverable(error) ? error : null;
const settled = !isLoading && (error === null || (ceiling !== null && folder !== null));

return (
<div className="file-browser" data-testid="file-browser">
<Breadcrumbs crumbs={breadcrumbs} onNavigate={navigateTo} />
<DeadLetterNotice deadLetters={view?.deadLetters ?? []} />
{error && (
{ceiling !== null && (
<div className="file-browser-notice" role="status" data-testid="file-browser-notice">
<span className="file-browser-notice-message">{ceiling.message}</span>
<button
type="button"
className="file-browser-notice-retry"
onClick={() => store.refresh()}
>
[retry]
</button>
</div>
)}
{error !== null && ceiling === null && (
<p className="file-browser-error" role="alert" data-testid="file-browser-error">
{error.message}
</p>
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
import type { ReactNode } from 'react';
import { useRefreshHints } from '../../engine/useRefreshHints';
import { NotificationToast } from '../NotificationToast';
import { StagingBanner } from '../StagingBanner';
import { AppFooter } from './AppFooter';
import { AppHeader } from './AppHeader';
import { AppSidebar } from './AppSidebar';
import { OfflineBanner } from './OfflineBanner';

interface AppShellProps {
children: ReactNode;
}

/** The signed-in frame: header, sidebar, scrollable main, footer. */
/**
* The signed-in frame: header, sidebar, scrollable main, footer, and the
* cross-cutting chrome that renders event-stream state only
* (blueprint/web-client.md "Composition").
*/
export function AppShell({ children }: AppShellProps) {
useRefreshHints();

return (
<div className="app-frame">
<StagingBanner />
<OfflineBanner />
<div className="app-shell" data-testid="app-shell">
<AppHeader />
<AppSidebar />
<main className="app-main">{children}</main>
<AppFooter />
</div>
<NotificationToast />
</div>
);
}
59 changes: 59 additions & 0 deletions apps/web/src/components/layout/OfflineBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fakeEngine, view } from '../../engine/testFakes';
import { EngineProvider } from '../../providers/EngineProvider';
import { OfflineBanner } from './OfflineBanner';

function draw(client: ReturnType<typeof fakeEngine>['client']) {
return render(
<EngineProvider createClient={() => client}>
<OfflineBanner />
</EngineProvider>
);
}

describe('the offline banner', () => {
it('stays down while the engine is reconciling', () => {
draw(fakeEngine().client);
expect(screen.queryByTestId('offline-banner')).toBeNull();
});

it('follows the engine reaching the offline rung', async () => {
const engine = fakeEngine();
draw(engine.client);

await act(async () => {
engine.emit({ kind: 'stalenessChanged', staleness: 'offline' });
});

await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy());
});

it('clears when the engine leaves that rung', async () => {
const engine = fakeEngine();
draw(engine.client);
await act(async () => {
engine.emit({ kind: 'stalenessChanged', staleness: 'offline' });
});
await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy());

await act(async () => {
engine.emit({ kind: 'snapshotUpdated' });
engine.pulls[0].resolve(view());
});

await waitFor(() => expect(screen.queryByTestId('offline-banner')).toBeNull());
});

it('renders no banner for the rungs above it', async () => {
const engine = fakeEngine();
draw(engine.client);

for (const rung of ['fresh', 'reconciling', 'stale'] as const) {
await act(async () => {
engine.emit({ kind: 'stalenessChanged', staleness: rung });
});
expect(screen.queryByTestId('offline-banner')).toBeNull();
}
});
});
20 changes: 20 additions & 0 deletions apps/web/src/components/layout/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useStaleness } from '../../engine/useStaleness';

/**
* The staleness ladder's bottom rung, at banner scale (blueprint/web-client.md
* "Staleness ladder rendering").
*/
export function OfflineBanner() {
if (useStaleness() !== 'offline') return null;

return (
<div className="offline-banner" role="status" data-testid="offline-banner">
<span className="offline-banner-icon" aria-hidden="true">
[//]
</span>
<span className="offline-banner-detail">
{'// OFFLINE - changes queue on this device and publish when the network returns'}
</span>
</div>
);
}
43 changes: 43 additions & 0 deletions apps/web/src/components/layout/StatusIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fakeEngine } from '../../engine/testFakes';
import { EngineProvider } from '../../providers/EngineProvider';
import { StatusIndicator } from './StatusIndicator';

function draw(client: ReturnType<typeof fakeEngine>['client']) {
return render(
<EngineProvider createClient={() => client}>
<StatusIndicator />
</EngineProvider>
);
}

describe('the status indicator', () => {
it('names the rung the engine reports', async () => {
const engine = fakeEngine();
draw(engine.client);
expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling');

for (const rung of ['fresh', 'stale', 'offline'] as const) {
await act(async () => {
engine.emit({ kind: 'stalenessChanged', staleness: rung });
});
await waitFor(() =>
expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung)
);
}
});

it('drives a manual refresh from the rung it renders', async () => {
const engine = fakeEngine();
draw(engine.client);
await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy());

await act(async () => {
fireEvent.click(screen.getByTestId('status-indicator'));
});

expect(engine.refreshes()).toBe(1);
expect(engine.pulls).toHaveLength(1);
});
});
11 changes: 8 additions & 3 deletions apps/web/src/components/layout/StatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useStaleness } from '../../engine/useStaleness';
import { useSnapshotStore } from '../../providers/EngineProvider';

/** The staleness ladder's rungs, as the footer renders them (#33 D4). */
const RUNGS = {
Expand All @@ -8,19 +9,23 @@ const RUNGS = {
offline: { label: 'offline', className: 'status-indicator--offline' },
} as const;

/** Where the vault sits on the staleness ladder. */
/** Where the vault sits on the staleness ladder, and the manual refresh. */
export function StatusIndicator() {
const staleness = useStaleness();
const store = useSnapshotStore();
const rung = RUNGS[staleness];

return (
<span
<button
type="button"
className={`status-indicator ${rung.className}`}
data-testid="status-indicator"
data-staleness={staleness}
title="Refresh now"
onClick={() => store.refresh()}
>
<span className="status-indicator-dot" aria-hidden="true" />
{rung.label}
</span>
</button>
);
}
Loading