mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
@@ -1,7 +1,7 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -21,6 +21,45 @@ function buildAddon(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
/** The pill toggle of a top-level addon row. */
|
||||
function addonToggle(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
|
||||
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
|
||||
function subToggle(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
|
||||
function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.style.background === 'var(--text-primary)';
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -59,7 +98,7 @@ describe('AddonManager', () => {
|
||||
|
||||
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('No addons available');
|
||||
expect(await screen.findByText('No addons available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
||||
@@ -229,4 +268,360 @@ describe('AddonManager', () => {
|
||||
expect(() => render(<AddonManager />)).not.toThrow();
|
||||
await screen.findByText('Mystery Addon');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
|
||||
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Failed to update addon');
|
||||
expect(screen.getByText('No addons available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('No addons available');
|
||||
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
|
||||
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
|
||||
buildAddon({ id: 'todo', name: 'Todo List' }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Todo List');
|
||||
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
|
||||
|
||||
expect(isOn(subToggle('Immich'))).toBe(true);
|
||||
expect(isOn(subToggle('Unsplash'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
|
||||
]),
|
||||
http.put('/api/admin/addons/immich', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(subToggle('Immich'));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ enabled: true }));
|
||||
await screen.findByText('Addon updated');
|
||||
expect(isOn(subToggle('Immich'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
|
||||
]),
|
||||
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Unsplash');
|
||||
|
||||
await user.click(subToggle('Unsplash'));
|
||||
|
||||
await screen.findByText('Failed to update addon');
|
||||
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCollabFeature = vi.fn();
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
|
||||
onToggleCollabFeature={onToggleCollabFeature}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('Chat');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Polls')).toBeInTheDocument();
|
||||
expect(screen.getByText("What's Next")).toBeInTheDocument();
|
||||
expect(isOn(subToggle('Chat'))).toBe(true);
|
||||
expect(isOn(subToggle('Notes'))).toBe(false);
|
||||
|
||||
await user.click(subToggle('Polls'));
|
||||
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Collab');
|
||||
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText(/Request failed with status code 500/);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
// A hand-typed base URL is used for the next lookup on blur
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The model is trimmed before it is stored, the key is sent verbatim
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Connection');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -27,7 +27,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
}
|
||||
@@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={onToggleBagTracking}
|
||||
<button type="button" onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
@@ -222,7 +222,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
@@ -269,7 +269,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
@@ -399,7 +399,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
@@ -451,6 +451,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
@@ -459,7 +460,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -470,7 +471,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
@@ -509,11 +510,11 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
@@ -526,7 +527,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -589,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No MCP tokens have been created yet');
|
||||
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -214,16 +214,21 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
<button type="button" onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
@@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react'
|
||||
import { PLUGIN_PERMISSIONS } from '@trek/shared'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
@@ -62,6 +63,8 @@ interface PluginRow {
|
||||
keyFingerprint?: string | null
|
||||
/** Why an update was refused, if one was. `version` is the version that was refused. */
|
||||
updateBlock?: { code: string; detail: string | null; version: string | null } | null
|
||||
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
|
||||
updateHold?: boolean
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
@@ -92,19 +95,35 @@ interface RegistryItem {
|
||||
/** The full key — public, and carried in full because re-trust compares it exactly. */
|
||||
authorPublicKey?: string | null
|
||||
}
|
||||
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
|
||||
interface VersionInfo {
|
||||
version: string
|
||||
publishedAt: string | null
|
||||
size: number | null
|
||||
signed: boolean
|
||||
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
|
||||
trek: string | null
|
||||
compatible: boolean
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
/** Every published version, newest first — the version picker's data. */
|
||||
versions?: VersionInfo[]
|
||||
manifest: {
|
||||
permissions: string[]
|
||||
egress: string[]
|
||||
// Optional because a registry may omit an empty list — the detail view has to
|
||||
// degrade rather than throw halfway through its render.
|
||||
permissions?: string[]
|
||||
egress?: string[]
|
||||
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
|
||||
operatorEgress?: boolean
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
license: string | null
|
||||
icon: string | null
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
|
||||
capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -175,26 +194,14 @@ const HEALTH: Record<string, string> = {
|
||||
}
|
||||
|
||||
// Known permissions → human-readable i18n key; unknown ones render as raw code.
|
||||
const PERM_KEYS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content',
|
||||
'db:read:collab', 'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections',
|
||||
'db:read:categories', 'db:read:tags', 'db:read:todos', 'weather:read', 'rates:read', 'db:write:costs',
|
||||
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:write:reservations', 'db:write:accommodations', 'db:write:daynotes', 'db:write:packing',
|
||||
'db:write:tags', 'db:write:todos', 'db:write:atlas', 'db:write:vacay', 'db:write:journal', 'db:write:collections',
|
||||
'db:write:files', 'db:write:collab', 'db:write:members',
|
||||
'db:create:trips',
|
||||
'db:meta',
|
||||
'notify:send', 'ai:invoke', 'oauth:client',
|
||||
'events:subscribe', 'jobs:run',
|
||||
'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
|
||||
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
|
||||
]
|
||||
// Generated from the host's protocol/envelope.ts by
|
||||
// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy.
|
||||
const PERM_KEYS = PLUGIN_PERMISSIONS
|
||||
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
|
||||
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0)
|
||||
const pa = nums(a), pb = nums(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = pa[i] || 0, y = pb[i] || 0
|
||||
@@ -239,6 +246,11 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') })
|
||||
if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') })
|
||||
if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') })
|
||||
if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
|
||||
if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
|
||||
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
|
||||
@@ -295,13 +307,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title }
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success-soft text-success">
|
||||
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
|
||||
</span>
|
||||
)
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
@@ -399,6 +406,12 @@ export default function AdminPluginsPanel() {
|
||||
const [egressSaving, setEgressSaving] = useState(false)
|
||||
const [egressError, setEgressError] = useState('')
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
|
||||
// from the registry detail endpoint, which computes each version's compat verdict.
|
||||
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
|
||||
// A picked version OLDER than the installed one — held here until the admin consents
|
||||
// to the data risk (the plugin's data dir stays; older code may not understand it).
|
||||
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
|
||||
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
|
||||
// each must be shown, not silently overwritten by the last one.
|
||||
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
|
||||
@@ -427,10 +440,13 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
// Index the registry once per fetch: the version map the update badges read, plus the
|
||||
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
|
||||
// The map holds latestCompatible — the newest version THIS TREK can install (computed
|
||||
// server-side) — not the absolute latest, so the banner never counts an update the
|
||||
// update endpoint would refuse.
|
||||
const indexRegistry = (items: RegistryItem[]) => {
|
||||
const vers: Record<string, string> = {}
|
||||
const byId: Record<string, RegistryItem> = {}
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
|
||||
setLatest(vers)
|
||||
setRegById(byId)
|
||||
}
|
||||
@@ -558,7 +574,21 @@ export default function AdminPluginsPanel() {
|
||||
.catch(() => setErrorsFor({ id, rows: [] }))
|
||||
}
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
|
||||
// the admin just rolled it back, and the banner nagging them straight back would make
|
||||
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
|
||||
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
|
||||
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
|
||||
// wondering why no update shows. Null when the registry gives no range to point at.
|
||||
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
|
||||
const reg = regById[p.id]
|
||||
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
|
||||
if (latest[p.id] === reg.latest) return null
|
||||
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
|
||||
return range ? { version: reg.latest, range } : null
|
||||
}
|
||||
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
|
||||
@@ -646,9 +676,11 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
// `version` pins the exact version to install (rollback / explicit switch); omitted,
|
||||
// the server resolves the newest TREK-compatible version itself.
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
@@ -680,9 +712,36 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setRetrusting(false); refresh() })
|
||||
}
|
||||
|
||||
// "Change version…" on an installed row: fetch the per-version list (with the server's
|
||||
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
|
||||
// response can't populate a picker that was reopened for another row.
|
||||
const openVersionPicker = (p: PluginRow) => {
|
||||
setMenu(null)
|
||||
setVersionPicker({ plugin: p, versions: null, failed: false })
|
||||
adminApi.pluginDetail(p.id)
|
||||
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
|
||||
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
|
||||
}
|
||||
|
||||
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
|
||||
// that asks for explicit consent first. Any other switch is the ordinary update path.
|
||||
const pickVersion = (version: string) => {
|
||||
if (!versionPicker) return
|
||||
const p = versionPicker.plugin
|
||||
setVersionPicker(null)
|
||||
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
|
||||
else void runUpdate(p, version)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
// One after another: busy is a single slot, so parallel updates would leave the
|
||||
// other rows clickable mid-install and refresh once per plugin.
|
||||
const updateAll = async () => {
|
||||
for (const p of updatable) await runUpdate(p)
|
||||
}
|
||||
|
||||
// Installed list after search / type / status filters + sort.
|
||||
const shownInstalled = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
@@ -740,7 +799,7 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
|
||||
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{menu && <div role="presentation" className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -796,11 +855,11 @@ export default function AdminPluginsPanel() {
|
||||
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
|
||||
</div>
|
||||
<div className="sm:hidden flex items-center gap-2 shrink-0">
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
@@ -843,11 +902,11 @@ export default function AdminPluginsPanel() {
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
@@ -872,7 +931,7 @@ export default function AdminPluginsPanel() {
|
||||
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
|
||||
<ArrowUpCircle size={16} className="text-warning shrink-0" />
|
||||
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
|
||||
<button onClick={() => updatable.forEach(runUpdate)}
|
||||
<button type="button" onClick={() => void updateAll()}
|
||||
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.updateAll')}
|
||||
</button>
|
||||
@@ -886,9 +945,12 @@ export default function AdminPluginsPanel() {
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onChangeVersion={() => openVersionPicker(p)}
|
||||
onResume={() => void resumeUpdates(p)}
|
||||
onReviewBlock={() => setSignatureBlock({
|
||||
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
@@ -910,11 +972,11 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
|
||||
<button onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
<button type="button" onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-xs font-mono">
|
||||
{errorsFor.rows.length === 0 ? <p className="text-content-faint py-4 text-center">{t('admin.plugins.noErrors')}</p> :
|
||||
@@ -932,11 +994,11 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
{!egressFor.supported ? (
|
||||
@@ -950,7 +1012,7 @@ export default function AdminPluginsPanel() {
|
||||
{egressFor.hosts.map(h => (
|
||||
<div key={h} className="flex items-center justify-between gap-2 rounded-lg border border-edge-secondary px-3 py-2">
|
||||
<span className="text-sm font-mono text-content break-all">{h}</span>
|
||||
<button
|
||||
<button type="button"
|
||||
disabled={egressSaving}
|
||||
onClick={() => saveEgress(egressFor.hosts.filter(x => x !== h))}
|
||||
className="text-content-faint hover:text-danger disabled:opacity-50"
|
||||
@@ -965,7 +1027,7 @@ export default function AdminPluginsPanel() {
|
||||
placeholder="gotify.example.com"
|
||||
className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
disabled={egressSaving || !egressDraft.trim()}
|
||||
onClick={() => saveEgress([...egressFor.hosts, egressDraft.trim()])}
|
||||
className="rounded-lg bg-content px-3 py-2 text-sm text-surface disabled:opacity-50"
|
||||
@@ -991,6 +1053,20 @@ export default function AdminPluginsPanel() {
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmDowngrade}
|
||||
onClose={() => setConfirmDowngrade(null)}
|
||||
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }}
|
||||
title={t('admin.plugins.downgradeTitle')}
|
||||
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
|
||||
confirmLabel={t('admin.plugins.downgradeConfirm')}
|
||||
/>
|
||||
|
||||
{signatureBlock && (
|
||||
<SignatureBlockDialog
|
||||
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
|
||||
@@ -1044,7 +1120,7 @@ function sortLabel(s: SortKey, t: T): string {
|
||||
|
||||
function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) {
|
||||
return (
|
||||
<button onClick={onClick} role="tab" aria-selected={active}
|
||||
<button type="button" onClick={onClick} role="tab" aria-selected={active}
|
||||
className={`inline-flex items-center gap-2 text-[13px] font-medium px-3.5 py-1.5 rounded-lg transition-colors ${
|
||||
active ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}>
|
||||
{label}
|
||||
@@ -1065,7 +1141,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
|
||||
const active = value !== options[0]?.[0]
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
|
||||
<button type="button" onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
|
||||
className={`relative h-[38px] w-[38px] sm:w-auto px-0 sm:px-3 inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl border bg-surface-card text-[13px] text-content-secondary transition-colors whitespace-nowrap hover:border-content-faint ${
|
||||
active ? 'border-content-faint' : 'border-edge'}`}>
|
||||
{icon}
|
||||
@@ -1077,7 +1153,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
|
||||
{open && (
|
||||
<div className="absolute top-11 right-0 z-30 min-w-[180px] max-w-[calc(100vw-2rem)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{options.map(([v, lbl]) => (
|
||||
<button key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors hover:bg-surface-tertiary ${
|
||||
value === v ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
{lbl}<Check size={15} className={`ml-auto text-accent ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
@@ -1096,17 +1172,17 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
<PackageOpen size={26} className="text-content-faint" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-muted">{t('admin.plugins.empty')}</p>
|
||||
<button onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
|
||||
<button type="button" onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
|
||||
<Download size={14} /> {t('admin.plugins.tabDiscover')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
@@ -1144,7 +1220,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
|
||||
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
@@ -1159,12 +1235,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-warning">
|
||||
<ShieldAlert size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button onClick={onReviewBlock}
|
||||
<button type="button" onClick={onReviewBlock}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<Info size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<PauseCircle size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={busy === p.id}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
@@ -1182,7 +1278,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
Actionable, and warning-toned until at least one host exists, because
|
||||
until then the plugin cannot reach anything and looks silently broken. */}
|
||||
{p.operatorEgress && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onEgress}
|
||||
title={t('admin.plugins.allowedHosts.hint')}
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border transition-colors ${
|
||||
@@ -1212,7 +1308,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||
{hasUpdate && (
|
||||
<button onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
|
||||
<button type="button" onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-2 sm:px-2.5 py-1.5 rounded-full text-warning bg-warning-soft border border-warning/30 hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
<ArrowUpCircle size={13} /> <span className="hidden sm:inline">{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
@@ -1222,7 +1318,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
@@ -1234,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
@@ -1276,7 +1376,7 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
<button type="button" onClick={onClick}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors ${
|
||||
danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary hover:bg-surface-tertiary'}`}>
|
||||
{icon} {label}
|
||||
@@ -1350,7 +1450,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
@@ -1396,7 +1496,10 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
}, [item.id])
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : []
|
||||
const permissions = manifest?.permissions ?? []
|
||||
const egress = manifest?.egress ?? []
|
||||
const settings = manifest?.settings ?? []
|
||||
const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : []
|
||||
const repoUrl = `https://github.com/${item.repo}`
|
||||
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
@@ -1405,12 +1508,12 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<Screenshot url={item.screenshotUrl} className="w-full aspect-[16/9]" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<button onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
|
||||
<button type="button" onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 sm:gap-3.5 px-4 sm:px-5 -mt-7 relative z-[1]">
|
||||
@@ -1420,11 +1523,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<div className="flex-1 min-w-0 pt-8">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3>
|
||||
{item.reviewedAt && <ReviewedBadge t={t} compact />}
|
||||
{item.reviewedAt && <ReviewedBadge t={t} />}
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
@@ -1446,7 +1549,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4>
|
||||
{caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? (
|
||||
{caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? (
|
||||
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
@@ -1455,7 +1558,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{manifest.permissions.includes('db:own') && (
|
||||
{permissions.includes('db:own') && (
|
||||
<div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
|
||||
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
|
||||
</div>
|
||||
@@ -1465,11 +1568,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
|
||||
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{manifest.egress.map(h => (
|
||||
{egress.map(h => (
|
||||
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
|
||||
))}
|
||||
{/* The hosts above are NOT the whole story for this plugin: it talks to a
|
||||
@@ -1488,11 +1591,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && manifest.settings.length > 0 && (
|
||||
{manifest && settings.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{manifest.settings.map(s => (
|
||||
{settings.map(s => (
|
||||
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span>
|
||||
@@ -1507,7 +1610,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.detailsTitle')}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
|
||||
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
|
||||
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{sizeKb !== null && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
|
||||
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
@@ -1519,6 +1622,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every published version, installable individually. The compat verdict per
|
||||
version is SERVER-computed — an incompatible one is explained, never offered. */}
|
||||
{(detail?.versions?.length ?? 0) > 1 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.versionsTitle')}</h4>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{detail!.versions!.map(v => (
|
||||
<div key={v.version} className="flex items-center gap-2.5 py-1">
|
||||
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{v.compatible ? (
|
||||
!installed && (
|
||||
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.installCompatible', { version: v.version })}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
|
||||
@@ -1546,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
|
||||
}
|
||||
|
||||
/**
|
||||
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
|
||||
* verdict: the installed version is marked, a compatible one gets a Switch action (the
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
|
||||
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{failed && <p className="text-xs text-danger px-2.5 py-2">{t('admin.plugins.detailError')}</p>}
|
||||
{!failed && !versions && <p className="text-xs text-content-faint px-2.5 py-2">{t('common.loading')}</p>}
|
||||
{versions?.length === 0 && <p className="text-xs text-content-faint px-2.5 py-2">{t('admin.plugins.noVersions')}</p>}
|
||||
{versions?.map(v => {
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-surface-secondary transition-colors">
|
||||
<span className={`text-[13px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{current ? (
|
||||
<span className="text-[11.5px] font-medium text-content-faint">{t('admin.plugins.installed')}</span>
|
||||
) : v.compatible ? (
|
||||
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.versionSwitch', { version: v.version })}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A signature check refused an install/update.
|
||||
*
|
||||
@@ -1580,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
: 'admin.plugins.sig.invalidBody'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1604,12 +1784,12 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button onClick={onClose}
|
||||
<button type="button" onClick={onClose}
|
||||
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
|
||||
</button>
|
||||
{offerRetrust && (
|
||||
<button onClick={() => onRetrust(version, newKey)} disabled={busy}
|
||||
<button type="button" onClick={() => onRetrust(version, newKey)} disabled={busy}
|
||||
className="text-xs font-semibold px-4 py-2 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.sig.retrustConfirm')}
|
||||
</button>
|
||||
@@ -1644,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1683,8 +1863,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
|
||||
<button onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
|
||||
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
|
||||
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1704,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
|
||||
]
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1724,7 +1904,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
: t('admin.plugins.dep.requires', { version: r.constraint })}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
|
||||
<button type="button" onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-3 py-1.5 rounded-full bg-accent text-accent-text hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0">
|
||||
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
|
||||
</button>
|
||||
@@ -1735,7 +1915,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<button onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1761,7 +1941,7 @@ function SecurityInfo({ t }: { t: T }) {
|
||||
<ShieldCheck size={14} className="text-content-faint shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-content-muted">{t('admin.plugins.reviewedMeaning')}</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full px-4 sm:px-6 py-2.5 border-t border-edge-secondary flex items-center justify-between gap-2 text-xs font-medium text-content-secondary hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<span className="flex items-center gap-2"><Lock size={13} className="shrink-0" /> <span className="text-left">{t('admin.plugins.security.title')}</span></span>
|
||||
<ChevronDown size={15} className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
|
||||
@@ -310,4 +310,292 @@ describe('BackupPanel', () => {
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-015: List request fails
|
||||
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
|
||||
server.use(http.get('/api/backup/list', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
|
||||
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
|
||||
expect(screen.getByText('No backups yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-016: Create fails
|
||||
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post('/api/backup/create', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getByTitle('Create Backup'))
|
||||
|
||||
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-017: Restore fails
|
||||
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () =>
|
||||
HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
|
||||
),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await user.click(await screen.findByText('Yes, restore'))
|
||||
|
||||
expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-018: Upload & restore happy path
|
||||
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
|
||||
const user = userEvent.setup()
|
||||
let uploaded = false
|
||||
server.use(
|
||||
http.post('/api/backup/upload-restore', () => {
|
||||
uploaded = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const reloadMock = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
|
||||
|
||||
expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
|
||||
expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
|
||||
// The picked file is cleared from the input so the same file can be chosen again
|
||||
expect(input.value).toBe('')
|
||||
|
||||
await user.click(screen.getByText('Yes, restore'))
|
||||
await waitFor(() => expect(uploaded).toBe(true))
|
||||
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// BKP-019: Upload & restore failure
|
||||
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
|
||||
await user.click(await screen.findByText('Yes, restore'))
|
||||
|
||||
expect(await screen.findByText('not a backup')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-020: Upload button forwards the click to the hidden file input
|
||||
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
await user.click(screen.getByTitle('Upload Backup'))
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// BKP-021: Delete declined / failing
|
||||
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
|
||||
const user = userEvent.setup()
|
||||
let deleteCalls = 0
|
||||
server.use(
|
||||
http.delete('/api/backup/:filename', () => {
|
||||
deleteCalls += 1
|
||||
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const trashBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
await user.click(trashBtn)
|
||||
expect(deleteCalls).toBe(0)
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockReturnValue(true)
|
||||
await user.click(trashBtn)
|
||||
|
||||
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-022: Auto settings save fails
|
||||
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Enable auto-backup')
|
||||
|
||||
await user.click(getToggleButton())
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-023: Size/date fallbacks
|
||||
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', () =>
|
||||
HttpResponse.json({
|
||||
backups: [
|
||||
{ filename: 'empty.zip', created_at: null, size: 0 },
|
||||
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('empty.zip')
|
||||
|
||||
expect(screen.getAllByText('-')).toHaveLength(2)
|
||||
expect(screen.getByText('5.0 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-024: Invalid server timezone
|
||||
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'Not/AZone',
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// BKP-025: 12h hour picker
|
||||
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
|
||||
const user = userEvent.setup()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Run at hour')
|
||||
|
||||
expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '12:00 AM' }))
|
||||
await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
|
||||
})
|
||||
|
||||
// BKP-026: Monthly interval
|
||||
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: '',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Day of month')
|
||||
|
||||
// No timezone from the server → the hint carries no timezone suffix
|
||||
expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1' }))
|
||||
await user.click(await screen.findByRole('button', { name: '15' }))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
|
||||
})
|
||||
|
||||
// BKP-027: Day of week + retention
|
||||
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
|
||||
const user = userEvent.setup()
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Day of week')
|
||||
|
||||
await user.click(screen.getByText('Fri'))
|
||||
await user.click(screen.getByText('Keep forever'))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
|
||||
})
|
||||
|
||||
// BKP-028: Download failure
|
||||
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getByText('Download'))
|
||||
|
||||
expect(await screen.findByText('Download failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-029: Confirm button hover styling
|
||||
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
const confirmBtn = await screen.findByText('Yes, restore')
|
||||
|
||||
fireEvent.mouseEnter(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
|
||||
fireEvent.mouseLeave(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@@ -208,7 +208,7 @@ export default function BackupPanel() {
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
@@ -222,7 +222,7 @@ export default function BackupPanel() {
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
@@ -247,7 +247,7 @@ export default function BackupPanel() {
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -274,14 +274,14 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
@@ -292,7 +292,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
@@ -318,11 +318,11 @@ export default function BackupPanel() {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
@@ -339,7 +339,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
@@ -386,7 +386,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
@@ -421,7 +421,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -440,7 +440,7 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
@@ -458,11 +458,15 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
@@ -497,14 +501,14 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-020
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Categories');
|
||||
expect(await screen.findByText('Categories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
expect(await screen.findByText('New Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Manage categories for places');
|
||||
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||
@@ -156,4 +156,148 @@ describe('CategoryManager', () => {
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
|
||||
server.use(http.get('/api/categories', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
|
||||
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
|
||||
expect(screen.getByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
|
||||
),
|
||||
http.put('/api/categories/5', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
|
||||
const nameInput = screen.getByDisplayValue('Hotels');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Lodging');
|
||||
await user.click(screen.getByTitle('Hotel'));
|
||||
await user.click(screen.getByText('Update'));
|
||||
|
||||
expect(await screen.findByText('Category updated')).toBeInTheDocument();
|
||||
expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' });
|
||||
expect(screen.getByText('Lodging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
|
||||
await user.click(screen.getByText('New Category'));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
|
||||
await user.click(screen.getByText('Create'));
|
||||
|
||||
expect(await screen.findByText('name already taken')).toBeInTheDocument();
|
||||
// The form stays open so the name can be corrected
|
||||
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
|
||||
expect(deleteCalled).toBe(false);
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
|
||||
expect(await screen.findByText('category in use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
|
||||
// Empty name → the preview falls back to the generic label
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Beach day');
|
||||
await user.click(screen.getByTitle('Beach'));
|
||||
|
||||
const preview = screen.getByText('Beach day');
|
||||
expect(preview).toHaveStyle({ color: '#6366f1' });
|
||||
|
||||
await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]);
|
||||
expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' });
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
|
||||
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {});
|
||||
await user.click(screen.getByTitle('Choose custom color'));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: '#123456' } });
|
||||
await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' }));
|
||||
// A non-preset colour fills the custom swatch instead of showing the pipette
|
||||
expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
|
||||
|
||||
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
|
||||
await user.click(within(row).getAllByRole('button')[0]);
|
||||
|
||||
// Only the inline edit form remains, pre-filled with the row's name
|
||||
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function CategoryManager() {
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingId) {
|
||||
@@ -194,7 +194,7 @@ export default function CategoryManager() {
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={handleStartCreate}
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
@@ -235,11 +235,11 @@ export default function CategoryManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleStartEdit(cat)}
|
||||
<button type="button" onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
<button type="button" onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildAdmin } from '../../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
|
||||
|
||||
// The tile preview would pull Leaflet into jsdom; the panel only needs it to render.
|
||||
vi.mock('../Map/MapView', () => ({
|
||||
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
|
||||
}));
|
||||
|
||||
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
|
||||
const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11';
|
||||
const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
|
||||
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
|
||||
const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
|
||||
function stubDefaults(initial: Record<string, unknown> = {}) {
|
||||
const state: Record<string, unknown> = { ...initial };
|
||||
const puts: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
|
||||
http.put('/api/admin/default-user-settings', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
puts.push(body);
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (value === null) delete state[key];
|
||||
else state[key] = value;
|
||||
}
|
||||
return HttpResponse.json({ ...state });
|
||||
}),
|
||||
);
|
||||
return { puts, state };
|
||||
}
|
||||
|
||||
function withToast() {
|
||||
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
|
||||
}
|
||||
|
||||
/** The selected option button is the one drawn with the strong border token. */
|
||||
function isActive(button: HTMLElement): boolean {
|
||||
return (button.style.border || '').includes('var(--text-primary)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The reset link sits inside the field's own <label>; because a button is a labelable
|
||||
* element the wrapping label becomes its accessible name, so it is queried positionally.
|
||||
*/
|
||||
function resetLink(label: string): HTMLElement {
|
||||
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
|
||||
if (!el) throw new Error(`no label found for ${label}`);
|
||||
return within(el).getByRole('button');
|
||||
}
|
||||
|
||||
function hasResetLink(label: string): boolean {
|
||||
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
const choices = await screen.findAllByRole('button', { name: option });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
}
|
||||
|
||||
describe('DefaultUserSettingsTab', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
stubDefaults();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-001: shows the loading placeholder until the defaults arrive', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-002: renders every field with no reset links while nothing is set', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
|
||||
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
|
||||
}
|
||||
for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
|
||||
expect(hasResetLink(label)).toBe(false);
|
||||
}
|
||||
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-003: a failing load still renders the panel with built-in defaults', async () => {
|
||||
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<DefaultUserSettingsTab />);
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(hasResetLink('Map engine')).toBe(false);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-004: picking a colour mode saves it and confirms with a toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText('Default saved')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ dark_mode: 'dark' }]);
|
||||
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true));
|
||||
expect(resetLink('Color Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-005: a legacy boolean dark_mode still highlights the matching option', async () => {
|
||||
stubDefaults({ dark_mode: true });
|
||||
const { unmount } = render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(false);
|
||||
unmount();
|
||||
|
||||
stubDefaults({ dark_mode: false });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(true);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Auto' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-006: unit and time-format options each save their own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
|
||||
await waitFor(() => expect(resetLink('Temperature Unit')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
|
||||
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([
|
||||
{ temperature_unit: 'fahrenheit' },
|
||||
{ distance_unit: 'imperial' },
|
||||
{ time_format: '12h' },
|
||||
]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(isActive(screen.getByRole('button', { name: '°C Celsius' }))).toBe(true);
|
||||
await user.click(resetLink('Temperature Unit'));
|
||||
|
||||
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ temperature_unit: null }]);
|
||||
expect(state.temperature_unit).toBeUndefined();
|
||||
await waitFor(() => expect(hasResetLink('Temperature Unit')).toBe(false));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-008: the currency picker saves the chosen code and can be reset', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ default_currency: 'USD' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, 'USD $', 'EUR €');
|
||||
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
|
||||
|
||||
await user.click(resetLink('Display currency'));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ default_currency: null });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-009: the blur-booking-codes options save booleans', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([
|
||||
{ blur_booking_codes: true },
|
||||
{ blur_booking_codes: false },
|
||||
]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, 'Select template...', 'CartoDB Dark');
|
||||
|
||||
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
|
||||
expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(url);
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
const input = screen.getByPlaceholderText(TILE_PLACEHOLDER);
|
||||
// userEvent reads {...} as key descriptors, so the placeholders are omitted here
|
||||
await user.type(input, 'https://tiles.example.org/tile.png');
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/tile.png' }]));
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/tile.png');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-012: resetting the tile URL clears the input too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
|
||||
await user.click(resetLink('Map Template'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-013: leaflet hides the GL-only token and style fields', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
|
||||
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-014: switching to Mapbox stores the provider with its own style slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
|
||||
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-015: switching to MapLibre stores the OpenFreeMap default and hides the token field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'MapLibre (OpenFreeMap)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
|
||||
expect(await screen.findByText('Map style')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-016: switching back to the standard engine only stores the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Standard (free)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'leaflet' }]));
|
||||
await waitFor(() => expect(screen.queryByText('Map style')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
|
||||
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-018: a stored Mapbox style survives while the standard engine is active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'leaflet', mapbox_style: MAPBOX_DARK });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
|
||||
// Switching to Mapbox re-uses the stored slot instead of resetting it
|
||||
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK }]));
|
||||
expect(screen.getByDisplayValue(MAPBOX_DARK)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-019: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared Mapbox token');
|
||||
|
||||
const input = screen.getByPlaceholderText('pk.eyJ…');
|
||||
expect(input).toHaveValue('pk.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'pk.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared Mapbox token'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ mapbox_access_token: null }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-020: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
const input = screen.getByDisplayValue(OFM_LIBERTY);
|
||||
await user.clear(input);
|
||||
await user.type(input, 'https://example.com/custom.json');
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
|
||||
expect(input).toHaveValue(OFM_LIBERTY);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-021: the style dropdown writes the picked preset into the active provider slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await pickFromSelect(user, 'Mapbox Standard', 'Navigation Night');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: MAPBOX_NAV_NIGHT }]));
|
||||
expect(screen.getByDisplayValue(MAPBOX_NAV_NIGHT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-022: resetting the style restores the provider default in the field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(resetLink('Map style'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
|
||||
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-023: the Mapbox 3D and quality options start on their built-in defaults and save their own keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('3D buildings & terrain');
|
||||
|
||||
// 3D defaults to on, quality mode to off when neither is stored
|
||||
const threeD = within(screen.getByText('3D buildings & terrain').closest('div') as HTMLElement);
|
||||
expect(isActive(threeD.getByRole('button', { name: 'On' }))).toBe(true);
|
||||
const quality = within(screen.getByText('High-quality mode').closest('div') as HTMLElement);
|
||||
expect(isActive(quality.getByRole('button', { name: 'Off' }))).toBe(true);
|
||||
|
||||
await user.click(threeD.getByRole('button', { name: 'Off' }));
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }]));
|
||||
await user.click(quality.getByRole('button', { name: 'On' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ mapbox_quality_mode: true });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-024: a rejected save surfaces the request error instead of a success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-025: a rejected reset surfaces the request error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(resetLink('Time Format'));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
@@ -17,9 +18,10 @@ import {
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
@@ -34,6 +36,7 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -48,8 +51,8 @@ function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
|
||||
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
@@ -84,7 +87,7 @@ function OptionButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -107,15 +110,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -137,6 +143,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -151,7 +158,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
@@ -182,7 +189,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
created_at: String(new Date()),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
@@ -328,10 +335,30 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -378,7 +405,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Sent: simple-me');
|
||||
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
// The request handler never resolves, so sending stays true after the click settles
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
@@ -110,7 +110,20 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText(/failed|error/i);
|
||||
expect(await screen.findByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
@@ -157,4 +170,141 @@ describe('DevNotificationsPanel', () => {
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-011: the remaining self/admin type buttons each fire their own event', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await user.click(screen.getByText('Boolean → Me').closest('button')!);
|
||||
await screen.findByText('Sent: boolean-me');
|
||||
await user.click(screen.getByText('Navigate → Me').closest('button')!);
|
||||
await screen.findByText('Sent: navigate-me');
|
||||
await user.click(screen.getByText('Simple → All Admins').closest('button')!);
|
||||
await screen.findByText('Sent: simple-admins');
|
||||
await user.click(screen.getByText('version_available').closest('button')!);
|
||||
await screen.findByText('Sent: version_available');
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: ADMIN_USER.id,
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
|
||||
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
|
||||
expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tripId = Number((tripSelect as HTMLSelectElement).value);
|
||||
|
||||
for (const label of ['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']) {
|
||||
await user.click(screen.getByText(label).closest('button')!);
|
||||
await screen.findByText(`Sent: ${label}`);
|
||||
}
|
||||
|
||||
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
|
||||
for (const body of bodies) {
|
||||
expect(body.scope).toBe('trip');
|
||||
expect(body.targetId).toBe(tripId);
|
||||
expect(body.params).toMatchObject({ trip: 'Paris Adventure', tripId: String(tripId) });
|
||||
}
|
||||
expect(bodies[1].params).toMatchObject({ actor: 'testadmin', count: '5' });
|
||||
expect(bodies[2].params).toMatchObject({ preview: 'This is a test message preview.' });
|
||||
expect(bodies[3].params).toMatchObject({ category: 'Clothing' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-013: user-scoped events target the picked recipient', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
|
||||
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
|
||||
o => (o.textContent ?? '').includes('alice'),
|
||||
)!;
|
||||
await user.selectOptions(userSelect, aliceOption.value);
|
||||
const aliceId = Number(aliceOption.value);
|
||||
|
||||
await user.click(screen.getByText('trip_invite').closest('button')!);
|
||||
await screen.findByText(`Sent: trip_invite-${aliceId}`);
|
||||
await user.click(screen.getByText('vacay_invite').closest('button')!);
|
||||
await screen.findByText(`Sent: vacay_invite-${aliceId}`);
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: aliceId,
|
||||
params: { actor: 'testadmin', invitee: 'alice@example.com' },
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: aliceId,
|
||||
params: { actor: 'testadmin', planId: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
|
||||
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.error()),
|
||||
http.get('/api/admin/users', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
|
||||
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
const btn = screen.getByText('Simple → Me').closest('button')!;
|
||||
fireEvent.mouseEnter(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-card)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed'))
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
@@ -65,7 +66,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
await screen.findByText('This is a plain paragraph without any markdown syntax.');
|
||||
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
@@ -484,7 +484,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -18,6 +18,23 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
|
||||
function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
const row = screen.getByText(name).closest(selector) as HTMLElement;
|
||||
return within(row).getAllByRole('button');
|
||||
}
|
||||
|
||||
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
|
||||
const itemButtons = (name: string) => rowButtons(name, '.group');
|
||||
|
||||
/** Expands the single fixture template and waits for its content. */
|
||||
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText(firstChild);
|
||||
}
|
||||
|
||||
describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||
server.use(
|
||||
@@ -233,7 +250,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
await screen.findByText('Electronics');
|
||||
expect(await screen.findByText('Electronics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
@@ -263,7 +280,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.type(itemInput, 'Sandals');
|
||||
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||
await user.type(itemInput, '{Enter}');
|
||||
await screen.findByText('Sandals');
|
||||
expect(await screen.findByText('Sandals')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
@@ -295,7 +312,7 @@ describe('PackingTemplateManager', () => {
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
await screen.findByText('Shoes');
|
||||
expect(await screen.findByText('Shoes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
@@ -362,7 +379,7 @@ describe('PackingTemplateManager', () => {
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top{Enter}');
|
||||
await screen.findByText('Tank Top');
|
||||
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
@@ -508,4 +525,296 @@ describe('PackingTemplateManager', () => {
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-021: a failing template list toasts and shows the empty state', async () => {
|
||||
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
|
||||
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-022: a failing expand toasts and leaves the template without content', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-023: an empty name is not submitted and a failing create toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', () => {
|
||||
posts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('No templates created yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, ' {Enter}');
|
||||
expect(posts).toBe(0);
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Ski trip{Enter}');
|
||||
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
|
||||
expect(posts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-024: deleting the expanded template collapses it, a failing delete toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'in use' }, { status: 500 })
|
||||
: HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await screen.findByText('Template deleted');
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-025: a blank rename closes the editor, a failing rename toasts, blur commits', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.put('/api/admin/packing-templates/1', () => {
|
||||
puts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.clear(screen.getByDisplayValue('Beach Trip'));
|
||||
await user.type(screen.getByRole('textbox'), '{Enter}');
|
||||
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// Blurring the field commits the pending name — here the request fails
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Winter');
|
||||
await user.tab();
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-026: a blank category is not posted, a failing add toasts and X cancels', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
|
||||
http.post('/api/admin/packing-templates/1/categories', () => {
|
||||
posts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Add category');
|
||||
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, ' {Enter}');
|
||||
expect(posts).toBe(0);
|
||||
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
|
||||
const cancel = within(catInput.parentElement as HTMLElement).getAllByRole('button')[1];
|
||||
await user.click(cancel);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-027: a blank category rename closes the editor and a failing rename toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', () => {
|
||||
puts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
await user.clear(screen.getByDisplayValue('Clothing'));
|
||||
await user.tab();
|
||||
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-028: a failing category delete toasts and keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[2]);
|
||||
|
||||
expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-029: the add-item button posts the item, a failing add toasts and X closes the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
|
||||
posts += 1;
|
||||
return posts === 1
|
||||
? HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 0 } })
|
||||
: HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[0]);
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
const addRow = itemInput.parentElement as HTMLElement;
|
||||
expect(within(addRow).getAllByRole('button')[0]).toBeDisabled();
|
||||
|
||||
await user.type(itemInput, 'Sandals');
|
||||
await user.click(within(addRow).getAllByRole('button')[0]);
|
||||
await screen.findByText('Sandals');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Towel');
|
||||
await user.click(within(addRow).getAllByRole('button')[0]);
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(addRow).getAllByRole('button')[1]);
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-030: the item editor commits on the check button, cancels on X and ignores a blank name', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.put('/api/admin/packing-templates/1/items/100', () => {
|
||||
puts += 1;
|
||||
return puts === 1
|
||||
? HttpResponse.json({ success: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'T-shirt');
|
||||
|
||||
// A blank name just closes the editor
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const blank = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(blank);
|
||||
await user.click(within(blank.parentElement as HTMLElement).getAllByRole('button')[0]);
|
||||
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// X discards the pending name
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const editing = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(editing);
|
||||
await user.type(editing, 'Discarded');
|
||||
await user.click(within(editing.parentElement as HTMLElement).getAllByRole('button')[1]);
|
||||
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// The check button commits
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const editing2 = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(editing2);
|
||||
await user.type(editing2, 'Tank Top');
|
||||
await user.click(within(editing2.parentElement as HTMLElement).getAllByRole('button')[0]);
|
||||
await screen.findByText('Tank Top');
|
||||
expect(puts).toBe(1);
|
||||
|
||||
// A failing rename keeps the editor open and toasts
|
||||
await user.click(itemButtons('Tank Top')[0]);
|
||||
const editing3 = screen.getByDisplayValue('Tank Top');
|
||||
await user.clear(editing3);
|
||||
await user.type(editing3, 'Vest{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Vest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-031: a failing item delete toasts and keeps the item', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'T-shirt');
|
||||
|
||||
await user.click(itemButtons('T-shirt')[1]);
|
||||
|
||||
expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-032: the chevron button expands and collapses the template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[0]);
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[0]);
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,12 +115,13 @@ export default function PackingTemplateManager() {
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
|
||||
}
|
||||
|
||||
// Item CRUD
|
||||
const handleAddItem = async (catId: number) => {
|
||||
if (!newItemName.trim() || !expandedId) return
|
||||
// The name is already guaranteed non-empty by the button and the Enter handler.
|
||||
if (!expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
@@ -143,7 +144,7 @@ export default function PackingTemplateManager() {
|
||||
try {
|
||||
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
|
||||
}
|
||||
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
@@ -157,7 +158,7 @@ export default function PackingTemplateManager() {
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
@@ -170,8 +171,8 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -186,7 +187,7 @@ export default function PackingTemplateManager() {
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
@@ -196,14 +197,14 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
|
||||
@@ -227,11 +228,11 @@ export default function PackingTemplateManager() {
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -245,15 +246,15 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)}
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
@@ -267,9 +268,9 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
@@ -286,11 +287,11 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
|
||||
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Error');
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageMigrationStatus,
|
||||
type StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { formatBytes } from '../../../utils/formatBytes'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import Section from '../../Settings/Section'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
effectiveCategoryMap,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
settingsDocumentOf,
|
||||
setMirrorTargets,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel'
|
||||
import { useStorageAdmin } from './useStorageAdmin'
|
||||
|
||||
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
okLabel: string
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
|
||||
const [editing, setEditing] = useState<{
|
||||
initial: StorageBackend | null
|
||||
originalName: string | null
|
||||
mirror: BackendFormMirrorProps
|
||||
} | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
|
||||
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
|
||||
// Open/closed only — never a snapshot of candidates. Storing the candidate
|
||||
// array here would go stale the moment the operator edits a category while
|
||||
// the dialog is open; the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh instead.
|
||||
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
|
||||
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
|
||||
// Set by `save` right before it calls admin.save(): the pre-save mirror
|
||||
// target count per row name. Consumed (and cleared) by the effect below the
|
||||
// first time `admin.state` changes afterward — never touched otherwise, so
|
||||
// unrelated state changes (the backfill poll included) are no-ops here.
|
||||
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
|
||||
// Synchronous single-flight lock for the queue effect below: `admin.state`
|
||||
// only reflects a just-started migration once startMigration's awaited
|
||||
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
|
||||
// effect synchronously (still against the pre-POST `admin.state`) would
|
||||
// otherwise dequeue and POST the next candidate before the server has
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
const before = pendingPromptCheck.current
|
||||
pendingPromptCheck.current = null
|
||||
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
|
||||
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
|
||||
if (grown) setSyncPrompt(grown.name)
|
||||
}, [admin.state])
|
||||
|
||||
// Queued category migrations run strictly sequentially: once a slot opens
|
||||
// (no migration currently running), dequeue the next candidate and start
|
||||
// it. Depends on admin.state so a poll landing a terminal status re-fires
|
||||
// this without any user interaction. The in-flight lock closes the race
|
||||
// where setMigrationQueue(rest) re-fires this effect synchronously while
|
||||
// admin.state still shows the pre-POST world — without it, the next
|
||||
// candidate could dequeue and POST before the first is server-confirmed.
|
||||
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
|
||||
// rule spans backfills and migrations alike, so starting while one runs
|
||||
// would 409, and the queued candidate would be lost (no retry).
|
||||
//
|
||||
// A failed startMigration POST would otherwise strand `rest`: it was
|
||||
// already dequeued into local state before the POST, so on failure nothing
|
||||
// ever changes `migrationQueue` or `admin.state` again — the effect never
|
||||
// re-fires and the remaining candidates sit invisibly forever, silently
|
||||
// never migrated. On failure the whole remaining queue is explicitly
|
||||
// cleared (never retried — the operator re-triggers via Save), named in a
|
||||
// toast, and admin.state is refreshed so the panel reflects whatever the
|
||||
// failed attempt's category ended up as server-side.
|
||||
useEffect(() => {
|
||||
if (migrationQueue.length === 0 || !admin.state) return
|
||||
if (migrationStartInFlight.current) return
|
||||
if (admin.state.migrations.some((m) => m.status === 'running')) return
|
||||
if (admin.state.backfills.some((b) => b.status === 'running')) return
|
||||
const [next, ...rest] = migrationQueue
|
||||
migrationStartInFlight.current = true
|
||||
setMigrationQueue(rest)
|
||||
void admin.startMigration(next!.category, next!.toWire).then((error) => {
|
||||
migrationStartInFlight.current = false
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
if (rest.length > 0) {
|
||||
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
|
||||
setMigrationQueue([])
|
||||
}
|
||||
void admin.refreshState().catch(() => {})
|
||||
}
|
||||
})
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { state, draft } = admin
|
||||
|
||||
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
|
||||
// never shows a stale candidate set while it's open (fix: migration prompt
|
||||
// staleness). moveAndSave recomputes independently at confirm time rather
|
||||
// than reading this render-scoped value, since the click handler and the
|
||||
// render that produced it are not guaranteed to be the same one.
|
||||
const migrationCandidates = computeMigrationCandidates(draft, state)
|
||||
|
||||
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
|
||||
// mirror-target candidates are the visible primaries only.
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
|
||||
const { rows, degenerate } = foldBackends(state, draft)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const usageSums = usageByBackend(state, draft)
|
||||
|
||||
const startEdit = (row: FoldedBackendRow) => {
|
||||
// Editing a built-in creates a settings override row bearing its name —
|
||||
// the first-class relocation path (merge-by-name).
|
||||
setEditing({
|
||||
initial: row.backend,
|
||||
originalName: row.name,
|
||||
mirror: {
|
||||
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
|
||||
initialTargets: row.mirrorTargets,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
|
||||
const renamedFrom =
|
||||
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
|
||||
// Widened to StorageConfig: these edit helpers are version-blind (they
|
||||
// return plain StorageConfig), and admin.setDraft re-attaches the
|
||||
// draft's own `version` regardless of what shape it's handed.
|
||||
let next: StorageConfig | null = draft
|
||||
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
|
||||
next = upsertBackend(next, backend)
|
||||
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
|
||||
admin.setDraft(next)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
const assigned = isDegenerate
|
||||
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
|
||||
: row?.categories ?? []
|
||||
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
|
||||
return [
|
||||
t('storage.remove.body', { name }),
|
||||
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
|
||||
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const setCategory = (category: StorageCategory, primaryName: string) => {
|
||||
// Picking a mirrored primary routes through its adopted mirror silently.
|
||||
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const categories = { ...draft.categories }
|
||||
if (stateEntry.source === 'default' && target === stateEntry.backend) {
|
||||
delete categories[category] // back to the default → no longer settings-owned
|
||||
} else {
|
||||
categories[category] = target
|
||||
}
|
||||
admin.setDraft({ ...draft, categories })
|
||||
}
|
||||
|
||||
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
|
||||
// in-progress `draft`) mirror target counts; the effect above compares
|
||||
// them against the fresh post-save state to detect newly-added
|
||||
// replicas. Folding `draft` here would already include the unsaved
|
||||
// edit and mask the very growth this is meant to detect.
|
||||
const snapshotMirrorTargets = (): Map<string, number> => {
|
||||
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
|
||||
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
|
||||
}
|
||||
|
||||
const doPlainSave = async () => {
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
if (await admin.save()) toast.success(t('storage.saved'))
|
||||
else pendingPromptCheck.current = null
|
||||
}
|
||||
|
||||
// Recomputes the candidate set at confirm time — never trusts whatever was
|
||||
// true when the dialog opened. The operator can edit categories (or a
|
||||
// background poll can land a fresher usage scan) while the dialog is on
|
||||
// screen; the recomputed set is what actually drives both stripCategories
|
||||
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
|
||||
// so the two can never diverge from what the dialog most recently showed.
|
||||
const moveAndSave = async () => {
|
||||
const candidates = computeMigrationCandidates(draft, state)
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
|
||||
if (ok) {
|
||||
setMigrationQueue(candidates)
|
||||
toast.success(t('storage.saved'))
|
||||
} else {
|
||||
pendingPromptCheck.current = null
|
||||
}
|
||||
setMigratePromptOpen(false)
|
||||
}
|
||||
|
||||
const routeOnlySave = async () => {
|
||||
setMigratePromptOpen(false)
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (migrationCandidates.length > 0) {
|
||||
setMigratePromptOpen(true)
|
||||
return
|
||||
}
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const handleCancelMigration = async (m: StorageMigrationStatus) => {
|
||||
const error = await admin.cancelMigration(m.category)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
|
||||
|
||||
const handleRefreshStats = async () => {
|
||||
const error = await admin.refreshStats()
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev))
|
||||
const error = await admin.startBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
const error = await admin.cancelBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</div>
|
||||
)}
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
// row.mirrorName only exists when foldBackends adopted a draft mirror
|
||||
// for this row, so the draft lookup below cannot miss.
|
||||
const testCandidate = row.mirrorName
|
||||
? draft.backends.find((b) => b.name === row.mirrorName)!
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{degenerate.map(({ backend, reason }) => {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const selectedPrimary = primaryNameOf(state, draft, effective[category])
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={selectedPrimary}
|
||||
onChange={(value) => setCategory(category, String(value))}
|
||||
options={rows.map((row) => ({
|
||||
value: row.name,
|
||||
label:
|
||||
stateEntry.source === 'default' && row.name === stateEntry.backend
|
||||
? `${row.name} (${t('storage.categories.default')})`
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
onConfirm={() => {
|
||||
if (confirmRemove) {
|
||||
admin.setDraft(
|
||||
confirmRemove.degenerate
|
||||
? removeBackend(draft, confirmRemove.name)
|
||||
: removeBackendAndMirrors(state, draft, confirmRemove.name),
|
||||
)
|
||||
}
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
title={t('storage.remove.title')}
|
||||
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
|
||||
confirmLabel={t('storage.remove.title')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import BackendForm from './BackendForm';
|
||||
|
||||
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
|
||||
|
||||
type FormProps = Parameters<typeof BackendForm>[0];
|
||||
|
||||
function renderForm(overrides: Partial<FormProps> = {}) {
|
||||
const onCommit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<BackendForm
|
||||
initial={null}
|
||||
backendNames={NAMES}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onCommit, onCancel };
|
||||
}
|
||||
|
||||
const S3_INITIAL: StorageBackend = {
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
describe('BackendForm', () => {
|
||||
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
|
||||
renderForm();
|
||||
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
|
||||
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
|
||||
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
|
||||
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
|
||||
const { onCommit } = renderForm({ initial: S3_INITIAL });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
|
||||
const { onCommit } = renderForm();
|
||||
const apply = screen.getByRole('button', { name: 'Apply' });
|
||||
expect(apply).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
|
||||
expect(apply).toBeDisabled(); // root still empty
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
|
||||
expect(apply).toBeEnabled();
|
||||
fireEvent.click(apply);
|
||||
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
|
||||
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
|
||||
const { onCommit } = renderForm();
|
||||
// Switch to s3 via the type select (CustomSelect renders options into a portal).
|
||||
fireEvent.click(screen.getByText('Local'));
|
||||
fireEvent.click(screen.getByText('S3'));
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
|
||||
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
|
||||
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
|
||||
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
|
||||
expect(committed.options).toEqual({
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
|
||||
});
|
||||
expect(committed.options).not.toHaveProperty('region');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
|
||||
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
// Keeping the original name stays allowed.
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
|
||||
renderForm();
|
||||
fireEvent.click(screen.getByText('Local')); // open the type select
|
||||
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('S3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
|
||||
});
|
||||
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(2); // off-box (self) excluded
|
||||
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
|
||||
expect(screen.getByRole('note').textContent).toContain('slows every upload');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'off-box', type: 's3' }),
|
||||
['backups-local', 'uploads-local'],
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
type StorageBackend,
|
||||
type StorageBackendFieldDef,
|
||||
type StorageBackendTypeId,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
function valuesOf(backend: StorageBackend | null): FieldValues {
|
||||
if (!backend) return {}
|
||||
const values: FieldValues = {}
|
||||
for (const [key, value] of Object.entries(backend.options)) {
|
||||
values[key] = Array.isArray(value) ? value : String(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export interface BackendFormMirrorProps {
|
||||
/** Selectable replica targets (the form excludes its own name at render). */
|
||||
candidates: string[]
|
||||
initialTargets: string[]
|
||||
}
|
||||
|
||||
interface BackendFormProps {
|
||||
/** null = new backend */
|
||||
initial: StorageBackend | null
|
||||
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
|
||||
backendNames: string[]
|
||||
/**
|
||||
* Present on non-mirror backends: renders the Mirror-targets composer
|
||||
* (replicas-on-primary — panel-supplied chrome, never a registry field).
|
||||
* When present, onCommit's second argument is always an array.
|
||||
*/
|
||||
mirror?: BackendFormMirrorProps
|
||||
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
backendNames,
|
||||
mirror,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
|
||||
|
||||
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
||||
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
|
||||
const setValue = (key: string, value: string | string[]) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
const filled = (field: StorageBackendFieldDef): boolean => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
|
||||
const canApply =
|
||||
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
|
||||
|
||||
const apply = () => {
|
||||
const options: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
options[field.key] = Array.isArray(value) ? value : []
|
||||
continue
|
||||
}
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
if (text === '' && !field.required) continue // omitted → the shared schema default applies
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
// The options were built from the same field defs the schema is generated
|
||||
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
|
||||
const payload = { name: name.trim(), type, options } as StorageBackend
|
||||
// Arity matters: the landed tests pin single-argument calls when no mirror
|
||||
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
|
||||
// mismatch), so the second argument exists only when the composer does.
|
||||
if (mirror) onCommit(payload, targets)
|
||||
else onCommit(payload)
|
||||
}
|
||||
|
||||
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
field.key,
|
||||
e.target.checked
|
||||
? [...selected, candidate]
|
||||
: selected.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<input
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
e.target.checked
|
||||
? [...targets, candidate]
|
||||
: targets.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
categoriesPointingAt,
|
||||
computeMigrationCandidates,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
mirrorsReferencing,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
setMirrorTargets,
|
||||
settingsDocumentOf,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
} from './storageModel';
|
||||
|
||||
const STATE: StorageAdminState = {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
|
||||
{
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
source: 'settings',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
categories: ['covers'],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
files: { backend: 'uploads-local', source: 'default' },
|
||||
journey: { backend: 'uploads-local', source: 'default' },
|
||||
covers: { backend: 'off-box', source: 'settings' },
|
||||
avatars: { backend: 'uploads-local', source: 'default' },
|
||||
places: { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-google': { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
||||
backups: { backend: 'backups-local', source: 'default' },
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
version: 5,
|
||||
configError: null,
|
||||
};
|
||||
|
||||
describe('settingsDocumentOf', () => {
|
||||
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
|
||||
const doc = settingsDocumentOf(STATE);
|
||||
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
|
||||
expect(doc.categories).toEqual({ covers: 'off-box' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
|
||||
expect(settingsDocumentOf(STATE).version).toBe(5);
|
||||
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove pre-check helpers', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas', backups: 'mir' },
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
|
||||
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
|
||||
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
|
||||
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('draft edits', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
||||
categories: {},
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
|
||||
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
|
||||
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
|
||||
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
|
||||
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
|
||||
expect(draft.backends).toHaveLength(1); // untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
|
||||
expect(removeBackend(draft, 'nas').backends).toEqual([]);
|
||||
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Hoisted to module scope (not just the mirror-fold describe below) so the
|
||||
// usageByBackend suite can reuse the same mirrored fixture the brief's test
|
||||
// snippet assumes is in scope.
|
||||
const MIRRORED_STATE: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{
|
||||
name: 'mirror', type: 'mirror', source: 'settings',
|
||||
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
||||
},
|
||||
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
|
||||
],
|
||||
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
|
||||
};
|
||||
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
|
||||
|
||||
describe('mirror fold/synthesize (replicas-on-primary)', () => {
|
||||
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
|
||||
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(degenerate).toEqual([]);
|
||||
expect(rows.map((r) => r.name)).not.toContain('mirror');
|
||||
const primary = rows.find((r) => r.name === 'backups-local')!;
|
||||
expect(primary.mirrorTargets).toEqual(['off-box']);
|
||||
expect(primary.mirrorName).toBe('mirror');
|
||||
expect(primary.categories).toContain('backups'); // via the mirror
|
||||
const replica = rows.find((r) => r.name === 'off-box')!;
|
||||
expect(replica.replicaOf).toEqual(['backups-local']);
|
||||
expect(replica.categories).toContain('covers'); // its own direct assignment survives
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
|
||||
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
|
||||
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
|
||||
const mirror = next.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.name).toBe('backups-local-mirror');
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
|
||||
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
|
||||
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
|
||||
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const mirrors = next.backends.filter((b) => b.type === 'mirror');
|
||||
expect(mirrors).toHaveLength(1);
|
||||
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
|
||||
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
|
||||
// Build the synthesized state locally: default backups routed via a fresh mirror…
|
||||
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
|
||||
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
|
||||
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories).not.toHaveProperty('backups');
|
||||
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
|
||||
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
|
||||
expect(repointed.categories.backups).toBe('backups-local');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
|
||||
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
|
||||
],
|
||||
categories: {},
|
||||
};
|
||||
const { rows, degenerate } = foldBackends(STATE, draft);
|
||||
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
|
||||
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
|
||||
['m2', 'duplicate-mirror'],
|
||||
['m-env', 'env-primary'],
|
||||
['m-ghost', 'missing-primary'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas' },
|
||||
};
|
||||
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
|
||||
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
|
||||
expect(renamed.categories.covers).toBe('nas2');
|
||||
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
|
||||
const draft = mirroredDraft();
|
||||
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
|
||||
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
|
||||
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
|
||||
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
|
||||
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
|
||||
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
|
||||
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
|
||||
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories.backups).toBe('backups-local');
|
||||
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
|
||||
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
|
||||
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
|
||||
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
|
||||
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
|
||||
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
|
||||
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replicaCandidates', () => {
|
||||
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
// uploads-local/place-photos-local serve categories directly;
|
||||
// backups-local serves backups through the mirror it is the primary of;
|
||||
// off-box carries a direct `covers` assignment. Nothing is offerable.
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
|
||||
const state: StorageAdminState = {
|
||||
...MIRRORED_STATE,
|
||||
backends: [
|
||||
...MIRRORED_STATE.backends,
|
||||
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
|
||||
],
|
||||
};
|
||||
const { rows } = foldBackends(state, settingsDocumentOf(state));
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageByBackend', () => {
|
||||
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
|
||||
const usage = {
|
||||
computedAt: 1,
|
||||
categories: Object.fromEntries(
|
||||
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
|
||||
) as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 5, bytes: 50 },
|
||||
};
|
||||
const state = { ...MIRRORED_STATE, usage };
|
||||
const sums = usageByBackend(state, mirroredDraft())!;
|
||||
// backups routes via the mirror → attributed to backups-local (the primary row).
|
||||
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
|
||||
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
|
||||
// this fixture's `places` and `photos-google` default to the env-sourced
|
||||
// place-photos-local backend, not uploads-local) + legacy photos.
|
||||
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
|
||||
// places + photos-google both default to place-photos-local.
|
||||
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
|
||||
// covers routes to off-box directly.
|
||||
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-017: null usage yields null', () => {
|
||||
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeMigrationCandidates', () => {
|
||||
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: {
|
||||
files: { objects: 3, bytes: 300 },
|
||||
journey: { objects: 0, bytes: 0 },
|
||||
} as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
|
||||
};
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
|
||||
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
|
||||
{
|
||||
name: 'off-box-mirror',
|
||||
type: 'mirror',
|
||||
source: 'settings',
|
||||
options: { primary: 'off-box', replicas: ['nas'] },
|
||||
categories: [],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
// Reassign 'files' to the mirrored primary's WIRE name — the same value
|
||||
// setCategory() writes into the draft when the operator picks a mirrored
|
||||
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
|
||||
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
|
||||
const base = settingsDocumentOf(STATE);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
|
||||
};
|
||||
|
||||
const strippedFiles = stripCategories(draft, STATE, ['files']);
|
||||
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
|
||||
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
|
||||
|
||||
const strippedCovers = stripCategories(draft, STATE, ['covers']);
|
||||
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
|
||||
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirrorProbeTargets', () => {
|
||||
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
|
||||
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
|
||||
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['off-box'] },
|
||||
};
|
||||
// The draft carries an EDITED off-box (unsaved endpoint change) but never
|
||||
// touches uploads-local (a built-in, never draft-owned).
|
||||
const editedOffBox = {
|
||||
name: 'off-box', type: 's3',
|
||||
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
|
||||
} as StorageBackend;
|
||||
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
|
||||
|
||||
const targets = mirrorProbeTargets(draft, STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
|
||||
// Primary: no draft entry — resolved from state (the live built-in).
|
||||
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
|
||||
// Replica: draft override wins — the unsaved edit, not the saved options.
|
||||
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['ghost'] },
|
||||
};
|
||||
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageConfigPut,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** One entry of the effective world (GET state), as the panels render it. */
|
||||
export type StateBackend = StorageAdminState['backends'][number]
|
||||
|
||||
/**
|
||||
* The settings-owned document the PUT carries: settings-sourced backends and
|
||||
* settings-sourced category assignments ONLY — built-ins and env backends are
|
||||
* never in the body unless the operator overrides one by name (in which case
|
||||
* the override already reports source 'settings'). Carries `version` from
|
||||
* the state it was built at (audit #7 — optimistic concurrency): the hook
|
||||
* re-attaches this same version on every subsequent local edit, so a PUT
|
||||
* always submits the version the operator's form was loaded at, not
|
||||
* whatever the server currently holds.
|
||||
*/
|
||||
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
|
||||
return {
|
||||
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(state.categories)
|
||||
.filter(([, entry]) => entry.source === 'settings')
|
||||
.map(([category, entry]) => [category, entry.backend]),
|
||||
) as StorageConfig['categories'],
|
||||
version: state.version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State options are already schema-shaped (the wire contract guarantees it;
|
||||
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
|
||||
* the cast re-attaches the discriminated-union type the loose state record dropped.
|
||||
*/
|
||||
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
|
||||
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
|
||||
}
|
||||
|
||||
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
|
||||
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
|
||||
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
|
||||
.filter(([, backend]) => backend === name)
|
||||
.map(([category]) => category)
|
||||
}
|
||||
|
||||
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
|
||||
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
|
||||
.map((b) => b.name)
|
||||
}
|
||||
|
||||
/** Replace by name in place, or append. Never mutates. */
|
||||
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
|
||||
const exists = draft.backends.some((b) => b.name === backend.name)
|
||||
return {
|
||||
...draft,
|
||||
backends: exists
|
||||
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
|
||||
: [...draft.backends, backend],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
|
||||
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
|
||||
}
|
||||
|
||||
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
|
||||
// The draft stays the wire document; folding is a view rule, synthesis a set
|
||||
// of pure draft operations. Mirror NAMES never reach the UI — every helper
|
||||
// that renders phrases mirrors as their primary.
|
||||
|
||||
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
|
||||
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
|
||||
|
||||
export interface FoldedBackendRow {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
source: 'built-in' | 'env' | 'settings'
|
||||
backend: StorageBackend
|
||||
/** Direct assignments ∪ assignments routed via the adopted mirror. */
|
||||
categories: StorageCategory[]
|
||||
/** Adopted mirror's replicas — [] when unmirrored. */
|
||||
mirrorTargets: string[]
|
||||
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
|
||||
mirrorName: string | null
|
||||
/** Primaries whose adopted mirror lists this backend as a replica. */
|
||||
replicaOf: string[]
|
||||
}
|
||||
|
||||
export interface DegenerateMirror {
|
||||
backend: StorageBackend
|
||||
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
|
||||
categories: StorageCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetchable content — replicating it is usually wasteful. photos-google is
|
||||
* the Google photo cache (place-photo-cache.service.ts); places is mostly
|
||||
* provider-derived imagery.
|
||||
*/
|
||||
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
|
||||
|
||||
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
|
||||
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
|
||||
const map = {} as Record<StorageCategory, string>
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
map[category] = draft.categories[category] ?? state.categories[category]!.backend
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** First draft mirror wrapping the named primary — the one the UI manages. */
|
||||
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
|
||||
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror expansion for the test probe (audit fix — the server's
|
||||
* `testBackend` resolves a mirror candidate's primary/replicas against its
|
||||
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
|
||||
* edits to those targets would silently test the SAVED options instead of
|
||||
* what's on the form). Draft overrides win per name, falling back to state
|
||||
* (built-in/env); a name that resolves to neither is dropped — that
|
||||
* primary/replica no longer exists, and the row already renders as
|
||||
* degenerate in that case.
|
||||
*/
|
||||
export function mirrorProbeTargets(
|
||||
draft: StorageConfig,
|
||||
state: StorageAdminState,
|
||||
mirror: StorageBackend,
|
||||
): StorageBackend[] {
|
||||
if (mirror.type !== 'mirror') return [mirror]
|
||||
const names = [mirror.options.primary, ...mirror.options.replicas]
|
||||
return names
|
||||
.map((name): StorageBackend | null => {
|
||||
const draftMatch = draft.backends.find((b) => b.name === name)
|
||||
if (draftMatch) return draftMatch
|
||||
const stateMatch = state.backends.find((b) => b.name === name)
|
||||
return stateMatch ? asWireBackend(stateMatch) : null
|
||||
})
|
||||
.filter((b): b is StorageBackend => b !== null)
|
||||
}
|
||||
|
||||
export function foldBackends(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
|
||||
const draftNames = draft.backends.map((b) => b.name)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const categoriesOf = (name: string): StorageCategory[] =>
|
||||
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
|
||||
|
||||
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
|
||||
// unless a draft row overrides the name; settings rows from the draft.
|
||||
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
|
||||
for (const b of state.backends) {
|
||||
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
|
||||
if (draftNames.includes(b.name)) continue
|
||||
if (b.source === 'settings') continue // removed from the draft, pending save
|
||||
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
|
||||
}
|
||||
for (const b of draft.backends) {
|
||||
if (isMirror(b)) continue
|
||||
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
|
||||
}
|
||||
|
||||
// Adoption: the first mirror per editable primary; the rest are degenerate —
|
||||
// rendered unfolded with a reason, never silently dropped.
|
||||
const adopted = new Map<string, MirrorBackend>()
|
||||
const degenerate: DegenerateMirror[] = []
|
||||
for (const mirror of draft.backends.filter(isMirror)) {
|
||||
const primary = base.find((row) => row.name === mirror.options.primary)
|
||||
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
|
||||
else adopted.set(primary.name, mirror)
|
||||
}
|
||||
|
||||
const rows = base.map((row): FoldedBackendRow => {
|
||||
const mirror = adopted.get(row.name)
|
||||
return {
|
||||
...row,
|
||||
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
|
||||
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
|
||||
mirrorName: mirror?.name ?? null,
|
||||
replicaOf: [...adopted.values()]
|
||||
.filter((m) => m.options.replicas.includes(row.name))
|
||||
.map((m) => m.options.primary),
|
||||
}
|
||||
})
|
||||
return { rows, degenerate }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows offerable as mirror replicas (the "Mirror targets" picker).
|
||||
*
|
||||
* A backend that already serves a category — directly, or as the primary of a
|
||||
* mirror a category routes to, which is exactly what `row.categories` unions —
|
||||
* can never also be a replica: the mirror's sync sweep makes each replica
|
||||
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
|
||||
* sweeps the replica's entire root. The server refuses such a config outright
|
||||
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
|
||||
* never offers one, so the operator can't walk into the refusal.
|
||||
*
|
||||
* Targets already selected on this mirror stay listed regardless — they must
|
||||
* remain visible to be unchecked (and a config that predates this rule is
|
||||
* repaired here, not hidden).
|
||||
*/
|
||||
export function replicaCandidates(
|
||||
rows: FoldedBackendRow[],
|
||||
selfName: string | null,
|
||||
currentTargets: readonly string[] = [],
|
||||
): string[] {
|
||||
return rows
|
||||
.filter((row) => row.name !== selfName)
|
||||
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
|
||||
.map((row) => row.name)
|
||||
}
|
||||
|
||||
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
|
||||
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
|
||||
const base = `${primaryName}-mirror`
|
||||
if (!taken.has(base)) return base
|
||||
for (let n = 2; ; n++) {
|
||||
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* targets non-empty: update the adopted mirror in place (or synthesize one)
|
||||
* and route every category that effectively resolves to the primary —
|
||||
* default-sourced included — through it. targets empty: dissolve, re-pointing
|
||||
* the mirror's categories at the primary and dropping entries that revert to
|
||||
* their built-in default.
|
||||
*/
|
||||
export function setMirrorTargets(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
primaryName: string,
|
||||
targets: string[],
|
||||
): StorageConfig {
|
||||
const adopted = adoptedMirrorFor(draft, primaryName)
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (!adopted) return draft
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] !== adopted.name) continue
|
||||
const stateEntry = state.categories[category]! // exhaustive by schema contract
|
||||
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
|
||||
else categories[category] = primaryName
|
||||
}
|
||||
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
|
||||
}
|
||||
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const mirror: StorageBackend = {
|
||||
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
|
||||
type: 'mirror',
|
||||
options: { primary: primaryName, replicas: [...targets] },
|
||||
}
|
||||
const backends = adopted
|
||||
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
|
||||
: [...draft.backends, mirror]
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (effective[category] === primaryName) categories[category] = mirror.name
|
||||
}
|
||||
return { backends, categories }
|
||||
}
|
||||
|
||||
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
|
||||
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
|
||||
return {
|
||||
backends: draft.backends.map((b) => {
|
||||
if (!isMirror(b)) return b
|
||||
return {
|
||||
...b,
|
||||
options: {
|
||||
primary: b.options.primary === oldName ? newName : b.options.primary,
|
||||
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
|
||||
},
|
||||
}
|
||||
}),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
|
||||
) as StorageConfig['categories'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a backend, every mirror wrapping it as primary, and every replica
|
||||
* reference to it in the remaining mirrors — a mirror stripped of its last
|
||||
* replica dissolves (with the same category re-pointing as an explicit
|
||||
* dissolve), so the draft never carries a phantom reference the composer
|
||||
* cannot render or repair.
|
||||
*/
|
||||
export function removeBackendAndMirrors(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
name: string,
|
||||
): StorageConfig {
|
||||
const stripped = draft.backends
|
||||
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
|
||||
.map((b) =>
|
||||
isMirror(b) && b.options.replicas.includes(name)
|
||||
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
|
||||
: b,
|
||||
)
|
||||
let next: StorageConfig = { ...draft, backends: stripped }
|
||||
for (const mirror of stripped.filter(isMirror)) {
|
||||
if (mirror.options.replicas.length > 0) continue
|
||||
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
|
||||
next = setMirrorTargets(state, next, mirror.options.primary, [])
|
||||
} else {
|
||||
// A degenerate duplicate stripped empty: drop it and re-point its categories.
|
||||
const categories = { ...next.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
|
||||
}
|
||||
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
|
||||
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter(isMirror)
|
||||
.filter((m) => m.options.replicas.includes(name))
|
||||
.map((m) => m.options.primary)
|
||||
}
|
||||
|
||||
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
|
||||
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
|
||||
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
|
||||
if (draftMirror) return draftMirror.options.primary
|
||||
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
|
||||
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
|
||||
return backendName
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage sums per VISIBLE backend row: each category's numbers land on the row
|
||||
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
|
||||
* the legacy photos directory lands on uploads-local — the only backend the
|
||||
* served-legacy `photos` category ever resolves to.
|
||||
*/
|
||||
export function usageByBackend(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): Record<string, { objects: number; bytes: number }> | null {
|
||||
if (!state.usage) return null
|
||||
const sums: Record<string, { objects: number; bytes: number }> = {}
|
||||
const add = (row: string, entry: { objects: number; bytes: number }) => {
|
||||
const current = (sums[row] ??= { objects: 0, bytes: 0 })
|
||||
current.objects += entry.objects
|
||||
current.bytes += entry.bytes
|
||||
}
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const entry = state.usage.categories[category]
|
||||
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
|
||||
}
|
||||
// The served-legacy `photos` directory always resolves to uploads-local —
|
||||
// hardcoded, not derived, because it is not one of the configurable categories.
|
||||
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
|
||||
|
||||
/** One reassigned category the operator might want to move existing objects for. */
|
||||
export interface MigrationCandidate {
|
||||
category: StorageCategory
|
||||
from: string
|
||||
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
|
||||
to: string
|
||||
/**
|
||||
* The RAW effective draft backend name for this category — possibly a
|
||||
* mirror's wire name when `to` is a mirrored primary. The migration POST
|
||||
* must carry this, not `to`: posting the bare primary would silently drop
|
||||
* replication for a category that was just routed onto a mirror.
|
||||
*/
|
||||
toWire: string
|
||||
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
|
||||
objects: number | null
|
||||
bytes: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category whose effective (draft-over-state) PRIMARY backend differs
|
||||
* from what's currently saved — normalized through `primaryNameOf` so simply
|
||||
* wrapping (or unwrapping) a mirror around the same primary never counts as
|
||||
* a candidate; mirroring is the existing sync/backfill flow, not a category
|
||||
* migration. Zero-object categories (usage present, 0 objects) are excluded —
|
||||
* nothing to move; null usage (never scanned) keeps the category as a
|
||||
* candidate with null counts, per spec ("unknown still prompts").
|
||||
*/
|
||||
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const candidates: MigrationCandidate[] = []
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
|
||||
const toWire = effective[category]
|
||||
const to = primaryNameOf(state, draft, toWire)
|
||||
if (from === to) continue
|
||||
const usage = state.usage?.categories[category]
|
||||
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
|
||||
}
|
||||
return candidates.filter((c) => c.objects !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert exactly the named categories in `draft` back to what's currently
|
||||
* saved in `saved` — same default/settings convention as the rest of this
|
||||
* file (drop the override when the saved entry is default-sourced, write the
|
||||
* explicit backend name otherwise). Every other draft edit is untouched.
|
||||
*/
|
||||
export function stripCategories(
|
||||
draft: StorageConfig,
|
||||
saved: StorageAdminState,
|
||||
categories: StorageCategory[],
|
||||
): StorageConfig {
|
||||
const next = { ...draft.categories }
|
||||
for (const category of categories) {
|
||||
const savedEntry = saved.categories[category]! // exhaustive by schema contract
|
||||
if (savedEntry.source === 'default') delete next[category]
|
||||
else next[category] = savedEntry.backend
|
||||
}
|
||||
return { ...draft, categories: next }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
|
||||
function isConflictError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
|
||||
}
|
||||
|
||||
export interface StorageAdmin {
|
||||
state: StorageAdminState | null
|
||||
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
|
||||
draft: StorageConfigPut | null
|
||||
dirty: boolean
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
|
||||
saveError: string | null
|
||||
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
|
||||
saveConflict: boolean
|
||||
saving: boolean
|
||||
testResults: StorageTestResults
|
||||
setDraft: (next: StorageConfig) => void
|
||||
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
|
||||
save: (overrideDraft?: StorageConfig) => Promise<boolean>
|
||||
test: (backend: StorageBackend) => Promise<void>
|
||||
/**
|
||||
* Probe each target individually (client-expanded mirror primary +
|
||||
* replicas — see `mirrorProbeTargets`) and merge the results into one
|
||||
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
|
||||
* sends the mirror stub itself — each call probes a concrete backend.
|
||||
*/
|
||||
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
|
||||
/** null on success; the verbatim server error otherwise. */
|
||||
startBackfill: (mirrorName: string) => Promise<string | null>
|
||||
cancelBackfill: (mirrorName: string) => Promise<string | null>
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
* The 409's only exit: throw the local edits away and reload the saved
|
||||
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
|
||||
* saveError. See the implementation comment for why refreshState can't do
|
||||
* this itself.
|
||||
*/
|
||||
discardDraft: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
|
||||
* model: self-contained, adminApi directly, deliberately no offline core —
|
||||
* hoster-level config is online-only).
|
||||
*/
|
||||
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
|
||||
const [state, setState] = useState<StorageAdminState | null>(null)
|
||||
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveConflict, setSaveConflict] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResults, setTestResults] = useState<StorageTestResults>({})
|
||||
|
||||
// Read imperatively from the poll effect/refreshState so a stale closure
|
||||
// never re-derives the draft out from under an in-flight edit.
|
||||
const dirtyRef = useRef(dirty)
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty
|
||||
}, [dirty])
|
||||
|
||||
// Read imperatively at refreshState's resolve time so a poll GET that was
|
||||
// in flight when a save started never applies after the save's own state.
|
||||
const savingRef = useRef(saving)
|
||||
useEffect(() => {
|
||||
savingRef.current = saving
|
||||
}, [saving])
|
||||
|
||||
// Bumped by every deliberate state application (initial load, save
|
||||
// success). A refreshState() call captures the seq before its GET and
|
||||
// drops the response if the seq moved on — i.e. a save applied its own
|
||||
// fresher state — while that GET was in flight.
|
||||
const stateSeq = useRef(0)
|
||||
|
||||
const applyState = useCallback((next: StorageAdminState) => {
|
||||
stateSeq.current += 1
|
||||
setState(next)
|
||||
setDraftState(settingsDocumentOf(next))
|
||||
setDirty(false)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.getStorage()
|
||||
.then((data) => {
|
||||
if (!cancelled) applyState(data)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
// Re-attaches the CURRENT draft's version onto whatever shape the caller
|
||||
// hands in (the storageModel edit helpers are version-blind — they operate
|
||||
// on plain StorageConfig — so this is the one place that keeps `version`
|
||||
// pinned to what the operator's form was loaded at, no matter how many
|
||||
// local edits run in between).
|
||||
const setDraft = useCallback((next: StorageConfig) => {
|
||||
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
|
||||
// `draft` when the operator has no unsaved edits in flight — never touches
|
||||
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
|
||||
// mask a pending save error.
|
||||
//
|
||||
// Race guard: a poll GET issued before a save's PUT resolves can resolve
|
||||
// AFTER the save applied its own (fresher) response, overwriting it with
|
||||
// the pre-save world. Capture the state-application sequence number before
|
||||
// the await and drop the response if it moved on, or if a save is
|
||||
// in-flight/just landed (read via a ref so it's current at resolve time).
|
||||
//
|
||||
// Declared before `save` (which calls it on a 409) — a hook's `const`s
|
||||
// must exist before another callback's dependency array references them.
|
||||
const refreshState = useCallback(async (): Promise<void> => {
|
||||
const seq = stateSeq.current
|
||||
const next = await adminApi.getStorage()
|
||||
if (stateSeq.current !== seq || savingRef.current) return
|
||||
setState(next)
|
||||
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
||||
}, [])
|
||||
|
||||
const save = useCallback(
|
||||
async (overrideDraft?: StorageConfig): Promise<boolean> => {
|
||||
if (!draft) return false
|
||||
// Always the DRAFT's own version, even for an overrideDraft (a variant
|
||||
// of the same draft with categories stripped, per stripCategories) —
|
||||
// both share the same version basis: what the operator's form was
|
||||
// loaded/refreshed at, never re-read from `state` here (that would
|
||||
// defeat the whole check — see setDraft's re-attach comment above).
|
||||
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
try {
|
||||
// The response is the fresh effective world, never the request echo.
|
||||
applyState(await adminApi.updateStorage(body))
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
if (isConflictError(err)) {
|
||||
// Something else (another admin's save, or a category migration's
|
||||
// flip) wrote since this draft's version was captured. Refresh
|
||||
// `state` so the operator sees the fresh world, but the dirty
|
||||
// draft itself is left untouched for review (dirty is never
|
||||
// cleared here) — refreshState() already respects dirtyRef.
|
||||
setSaveError(conflictError)
|
||||
setSaveConflict(true)
|
||||
void refreshState().catch(() => {})
|
||||
} else {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[draft, applyState, genericError, conflictError, refreshState],
|
||||
)
|
||||
|
||||
/**
|
||||
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
|
||||
* the version it was built at. refreshState deliberately leaves a dirty
|
||||
* draft's version alone — bumping it there would resurrect audit #7, where
|
||||
* the background poll floats a mid-edit draft past a migration flip it
|
||||
* never carried — so nothing else can ever move it, and every retry 409s
|
||||
* again. The operator's way out is to say so explicitly: discard the local
|
||||
* edits, reload the saved settings, and start over from the fresh version.
|
||||
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
|
||||
* version is current, and applyState clears dirty/saveError with it.
|
||||
*/
|
||||
const discardDraft = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
applyState(await adminApi.getStorage())
|
||||
} catch (err: unknown) {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
setSaveConflict(false)
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
const test = useCallback(
|
||||
async (backend: StorageBackend): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(backend)
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, genericError)
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
const testMirror = useCallback(
|
||||
async (key: string, targets: StorageBackend[]): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
|
||||
const settled = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(target)
|
||||
return result.targets[0] ?? { name: target.name, ok: result.ok }
|
||||
} catch (err: unknown) {
|
||||
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
// While any backfill or category migration is running, poll GET state so
|
||||
// progress/counts advance without the operator refreshing the page.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
// A transient poll failure is retried on the next tick — it must never
|
||||
// surface as a toast or interrupt the operator's in-progress edit.
|
||||
void refreshState().catch(() => {})
|
||||
}, BACKFILL_POLL_MS)
|
||||
return () => clearInterval(iv)
|
||||
}, [state, refreshState])
|
||||
|
||||
const startBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const startMigration = useCallback(
|
||||
async (category: string, to: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageMigration(category, to)
|
||||
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
|
||||
// queue effect's in-flight lock releases as soon as this promise
|
||||
// resolves, so admin.state must already reflect the just-started
|
||||
// migration by then — otherwise the next queued candidate can
|
||||
// dequeue and POST before the server has confirmed this one.
|
||||
await refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelMigration = useCallback(
|
||||
async (category: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageMigration(category)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const refreshStats = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const usage = await adminApi.refreshStorageStats()
|
||||
setState((prev) => (prev ? { ...prev, usage } : prev))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
return {
|
||||
state,
|
||||
draft,
|
||||
dirty,
|
||||
loading,
|
||||
loadError,
|
||||
saveError,
|
||||
saveConflict,
|
||||
saving,
|
||||
testResults,
|
||||
setDraft,
|
||||
save,
|
||||
test,
|
||||
testMirror,
|
||||
startBackfill,
|
||||
cancelBackfill,
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user