feat(client): descriptive category names, descriptions and id badges in the storage panel

This commit is contained in:
jubnl
2026-08-20 20:44:14 +02:00
parent 2dff879c31
commit a1e8f0f7d3
4 changed files with 57 additions and 10 deletions
@@ -74,7 +74,7 @@ describe('AdminStoragePanel', () => {
const uploads = backendRow('uploads-local');
expect(within(uploads).getByText('Local')).toBeInTheDocument();
expect(within(uploads).getByText('Built-in')).toBeInTheDocument();
expect(within(uploads).getByText(/Used by: files/)).toBeInTheDocument();
expect(within(uploads).getByText(/Used by: Trip documents/)).toBeInTheDocument();
const env = backendRow('place-photos-local');
expect(within(env).getByText('Environment')).toBeInTheDocument();
expect(within(env).getByText(/read-only/)).toBeInTheDocument();
@@ -155,7 +155,7 @@ describe('AdminStoragePanel', () => {
}),
);
fireEvent.click(within(backendRow('off-box')).getByRole('button', { name: 'Remove' }));
expect(screen.getByText(/Still assigned to: covers/)).toBeInTheDocument();
expect(screen.getByText(/Still assigned to: Cover images/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove backend' }));
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await screen.findByText('Storage configuration saved');
@@ -219,7 +219,7 @@ describe('AdminStoragePanel', () => {
expect(screen.queryByTestId('storage-backend-mirror')).not.toBeInTheDocument();
const primary = backendRow('backups-local');
expect(within(primary).getByText('Mirrored to: off-box')).toBeInTheDocument();
expect(within(primary).getByText(/Used by: .*backups/)).toBeInTheDocument();
expect(within(primary).getByText(/Used by: .*Backups/)).toBeInTheDocument();
const replica = backendRow('off-box');
expect(within(replica).getByText('Replica of: backups-local')).toBeInTheDocument();
});
@@ -323,4 +323,14 @@ describe('AdminStoragePanel', () => {
expect(within(row).getByRole('button', { name: 'Remove' })).toBeInTheDocument();
expect(within(row).queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
it('FE-ADMIN-STOR-017: category rows show the display name, the raw id badge, and the description; photos is gone', async () => {
await renderPanel();
const files = categoryRow('files');
expect(within(files).getByText('Trip documents')).toBeInTheDocument();
expect(within(files).getByText('files')).toBeInTheDocument(); // the monospace id badge
expect(within(files).getByText(/tickets, PDFs, booking confirmations/)).toBeInTheDocument();
expect(within(categoryRow('photos-google')).getByText(/re-fetchable, safe to lose/)).toBeInTheDocument();
expect(screen.queryByTestId('storage-category-photos')).not.toBeInTheDocument();
});
});
@@ -24,6 +24,10 @@ import {
} 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
@@ -106,7 +110,7 @@ export default function AdminStoragePanel(): React.ReactElement {
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: assigned.join(', ') }) : '',
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
@@ -194,7 +198,7 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: row.categories.join(', ') })
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{row.mirrorTargets.length > 0 && (
@@ -293,9 +297,13 @@ export default function AdminStoragePanel(): React.ReactElement {
const viaMirror = effective[category] !== selectedPrimary
return (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
<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>
<CustomSelect
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
@@ -45,6 +45,10 @@ function valuesOf(backend: StorageBackend | null): FieldValues {
return values
}
/** 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(', ')
/** Same behavior contract as the desktop BackendForm, rendered on the M* primitives. */
function MBackendForm({
initial,
@@ -299,7 +303,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: assigned.join(', ') }) : '',
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
@@ -377,7 +381,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
</div>
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: row.categories.join(', ') })
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{row.mirrorTargets.length > 0 && (
@@ -514,7 +518,18 @@ export default function MAdminStoragePanel(): React.ReactElement {
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
const viaMirror = effective[category] !== selectedPrimary
return (
<MAdminField key={category} label={t(`storage.category.${category}`)}>
<MAdminField
key={category}
label={
<>
{t(`storage.category.${category}`)}{' '}
<span className="rounded border border-[color:var(--m-rowbr)] px-1 font-geist text-[0.625rem] font-normal text-m-faint">
{category}
</span>
</>
}
hint={t(`storage.categoryDesc.${category}`)}
>
<div data-testid={`m-storage-category-${category}`} className="contents">
<MSetSelectRow
label={
@@ -151,9 +151,23 @@ describe('MAdminStoragePanel', () => {
);
fireEvent.click(within(screen.getByTestId('m-storage-category-places')).getByRole('button'));
fireEvent.click(within(screen.getByRole('dialog', { name: 'Place images' })).getByRole('button', { name: 'backups-local' })); // picker option = primary name
expect(screen.getByText(/re-fetchable/)).toBeInTheDocument();
// getByText(/re-fetchable/) is now ambiguous — the photos-google/photos-trek category
// descriptions also say "re-fetchable". The cache warning is the only role="note" on
// screen here (the mirror-targets latency note only renders while a form is open).
expect(screen.getByRole('note')).toHaveTextContent(/re-fetchable/);
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await screen.findByText('Storage configuration saved');
expect((putBody as { categories: Record<string, string> }).categories.places).toBe('mirror');
});
it('FE-MOB-MSTOR-009: category rows show display name, id badge and description; photos is gone', async () => {
await renderPanel();
const files = screen.getByTestId('m-storage-category-files');
// The testid wraps only the select row — assert name/badge/description through the field around it.
expect(screen.getByText('Trip documents')).toBeInTheDocument();
expect(screen.getByText('files')).toBeInTheDocument();
expect(screen.getByText(/tickets, PDFs, booking confirmations/)).toBeInTheDocument();
expect(within(files).getByRole('button')).toBeInTheDocument(); // row still tappable
expect(screen.queryByTestId('m-storage-category-photos')).not.toBeInTheDocument();
});
});