fix(uploads): refresh destination previews and improve queue layout
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 56s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / ts / ts (packages + web) (push) Failing after 4m28s
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 56s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / ts / ts (packages + web) (push) Failing after 4m28s
This commit is contained in:
1 parent
eb75437708
commit
30c3c85f89
18 files changed
+603
-402
No files matched your search
@@ -13,7 +13,7 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
||||
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
|
||||
const files = [
|
||||
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac' },
|
||||
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac' },
|
||||
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
|
||||
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
|
||||
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
|
||||
@@ -34,6 +34,10 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
return json(missing);
|
||||
}
|
||||
if (scenario === 'template') {
|
||||
if (url.pathname === '/api/v1/admin/uploads') {
|
||||
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
|
||||
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
|
||||
}
|
||||
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
|
||||
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
|
||||
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
|
||||
@@ -71,8 +75,19 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
|
||||
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
|
||||
await setup(page, 'queue');
|
||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('nas:/music/Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
|
||||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
||||
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
|
||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).uncheck();
|
||||
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
|
||||
@@ -131,3 +146,25 @@ test('keeps withdrawn proposals in their own filter', async ({ page }) => {
|
||||
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
|
||||
await setup(page, 'template');
|
||||
const sourcePath = new URL(page.url()).pathname;
|
||||
const navigate = async (path: string) => page.evaluate((path) => {
|
||||
history.pushState({}, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, path);
|
||||
await navigate('/admin/uploads');
|
||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||
await expect(page.getByText('nas:/music/Artist/Album/Song.flac', { exact: true })).toBeVisible();
|
||||
await navigate(sourcePath);
|
||||
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||||
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
||||
await section.getByRole('checkbox').check();
|
||||
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||||
await navigate('/admin/uploads');
|
||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||
await expect(page.getByText('nas:/music/Artist/Album/01. Song.flac', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
.page { width: 100%; min-width: 0; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
||||
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
|
||||
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { useId, useState } from 'react';
|
||||
import { needsFormatAttention } from './files';
|
||||
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -24,6 +25,8 @@ export function BatchCard({
|
||||
onRetry: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const contentId = useId();
|
||||
const first = files[0]!;
|
||||
const ids = files.map((f) => f.id);
|
||||
const all = ids.every((id) => selected.has(id));
|
||||
@@ -31,31 +34,36 @@ export function BatchCard({
|
||||
return (
|
||||
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||
<div className={u.batchHead}>
|
||||
<Button variant="ghost" className={u.batchToggle} aria-label={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} title={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} aria-expanded={!collapsed} aria-controls={contentId} onClick={() => setCollapsed((value) => !value)}>
|
||||
<Icon src={chevronRightIcon} size={20} className={!collapsed ? u.batchExpanded : undefined} />
|
||||
</Button>
|
||||
{selectable && (
|
||||
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||
)}
|
||||
<b>{first.author.username}</b>
|
||||
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
{t('uploads.edit_batch')}
|
||||
</Button>
|
||||
)}
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
</div>
|
||||
<div id={contentId} hidden={collapsed}>
|
||||
{files.map((f) => (
|
||||
<QueueRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
selectable={selectable}
|
||||
checked={selected.has(f.id)}
|
||||
onCheck={(on) => onToggle([f.id], on)}
|
||||
onEdit={() => onEdit(f)}
|
||||
onRetry={() => onRetry(f.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{files.map((f) => (
|
||||
<QueueRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
selectable={selectable}
|
||||
checked={selected.has(f.id)}
|
||||
onCheck={(on) => onToggle([f.id], on)}
|
||||
onEdit={() => onEdit(f)}
|
||||
onRetry={() => onRetry(f.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +163,10 @@ function QueueRow({
|
||||
<>
|
||||
<code className={u.target}>{f.target_rel_path}</code>
|
||||
<span className={u.sub}>{t('uploads.target_path_hint')}</span>
|
||||
{f.target_storage_path && <>
|
||||
<span className={u.placementLabel}>{t('uploads.target_full_path')}</span>
|
||||
<code className={u.target}>{f.target_storage_path}</code>
|
||||
</>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function PatternsPage({ id }: { id: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
|
||||
|
||||
@@ -72,6 +72,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
|
||||
onSuccess: async (src) => {
|
||||
queryClient.setQueryData(['source', id], src);
|
||||
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -179,6 +179,9 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.batchToggle { min-width: 44px; min-height: 44px; padding: 8px; }
|
||||
.batchExpanded { transform: rotate(90deg); }
|
||||
|
||||
.comment {
|
||||
flex-basis: 100%;
|
||||
color: var(--text);
|
||||
|
||||
Reference in new issue
Block a user