feat(library): exclude configured folders from scanning and analysis
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
This commit is contained in:
1 parent
0dc0384c78
commit
75ae2092ad
23 files changed
+837
-351
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { SessionProvider } from '../../app/session';
|
||||
import { mobileFixtures } from '../../test/mobileFixtures';
|
||||
import { SourceDialog } from './SourcesPage';
|
||||
|
||||
afterEach(cleanup);
|
||||
type Source = components['schemas']['Source'];
|
||||
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
|
||||
|
||||
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
if (req.method === 'PATCH' || req.method === 'POST') {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
return new Response(JSON.stringify(reject
|
||||
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
|
||||
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
const i18n = await createI18n('ru');
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const close = vi.fn();
|
||||
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
|
||||
<SourceDialog source={source} onClose={close} />
|
||||
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||
return { bodies, close, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
it('loads and saves one excluded path per line', async () => {
|
||||
const { bodies, close, user } = await fixture();
|
||||
const input = screen.getByLabelText('Не сканировать папки');
|
||||
expect(input).toHaveValue('Downloads_PumpEars');
|
||||
await user.type(input, '\nArtist/Samples\n');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
|
||||
});
|
||||
|
||||
it('clears the list explicitly so excluded folders can be scanned again', async () => {
|
||||
const { bodies, close, user } = await fixture();
|
||||
await user.clear(screen.getByLabelText('Не сканировать папки'));
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(bodies[0]?.excluded_dirs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps invalid paths in the editor and displays the server validation', async () => {
|
||||
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useId, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
|
||||
</div>
|
||||
<span className={l.location}>{location(source)}</span>
|
||||
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
|
||||
{source.status === 'error' && source.status_message && (
|
||||
<span className={l.message}>{source.status_message}</span>
|
||||
)}
|
||||
@@ -185,7 +186,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
|
||||
const INTERVALS = [0, 60, 360, 1440];
|
||||
|
||||
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||
export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -199,12 +200,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
|
||||
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
|
||||
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
|
||||
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
|
||||
const excludedDirsId = useId();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const common = {
|
||||
name: name.trim(),
|
||||
tag_priority: priority,
|
||||
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
|
||||
...(type === 'local'
|
||||
? { local_path: localPath }
|
||||
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
|
||||
@@ -224,11 +228,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
|
||||
void queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const err = save.error;
|
||||
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
|
||||
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs'];
|
||||
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
@@ -297,6 +304,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={l.field}>
|
||||
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
|
||||
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
|
||||
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
|
||||
disabled={source?.scan?.running ?? false}
|
||||
placeholder="Downloads_PumpEars"
|
||||
aria-invalid={!!fieldError(err, 'excluded_dirs')}
|
||||
aria-describedby={`${excludedDirsId}-hint`} />
|
||||
<span id={`${excludedDirsId}-hint`} className={s.note}>
|
||||
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={l.field}>
|
||||
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||
<Segmented
|
||||
|
||||
@@ -238,6 +238,20 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.excludedDirs {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: 13px var(--font-mono);
|
||||
}
|
||||
.excludedDirs:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.excludedDirs:disabled { opacity: 0.6; }
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
|
||||
Reference in new issue
Block a user