Merge pull request #591 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Fix pagination UI and refactor presets layout
This commit is contained in:
Abdulmohsen 2026-04-22 20:01:40 +03:00 committed by GitHub
commit fdc08989ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 232 additions and 96 deletions

4
API.md
View file

@ -1777,6 +1777,7 @@ Binary image data with appropriate headers
- `per_page` (optional): Items per page. Default: `config.default_pagination`. - `per_page` (optional): Items per page. Default: `config.default_pagination`.
- `sort` (optional): Comma-separated sort fields. Accepted values: `id`, `name`, `priority`, `default`, `created_at`, `updated_at`. Default: `priority,name`. - `sort` (optional): Comma-separated sort fields. Accepted values: `id`, `name`, `priority`, `default`, `created_at`, `updated_at`. Default: `priority,name`.
- `order` (optional): Comma-separated sort directions matching `sort`, or a single direction applied to every requested sort field. Accepted values: `asc`, `desc`. Default: `desc,asc`. - `order` (optional): Comma-separated sort directions matching `sort`, or a single direction applied to every requested sort field. Accepted values: `asc`, `desc`. Default: `desc,asc`.
- `exclude_defaults` (optional): When `true`, excludes system presets from the results. Default: `false`.
**Response**: **Response**:
```json ```json
@ -1806,10 +1807,9 @@ Binary image data with appropriate headers
**Notes**: **Notes**:
- `default: true` indicates this is a system default preset (cannot be modified or deleted) - `default: true` indicates this is a system default preset (cannot be modified or deleted)
- Default ordering remains `priority desc, name asc`
**Error Responses**: **Error Responses**:
- `400 Bad Request` - Invalid pagination or sorting query parameters - `400 Bad Request` - Invalid data was provided.
--- ---

View file

@ -180,25 +180,32 @@ class PresetsRepository(metaclass=Singleton):
per_page: int, per_page: int,
sort: str | None = None, sort: str | None = None,
order: str | None = None, order: str | None = None,
exclude_defaults: bool = False,
) -> tuple[list[PresetModel], int, int, int]: ) -> tuple[list[PresetModel], int, int, int]:
order_by = self._build_order_by(sort, order) order_by = self._build_order_by(sort, order)
async with self.session() as session: async with self.session() as session:
total: int = await self.count() total: int = await self.count(exclude_defaults=exclude_defaults)
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1 total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
if page > total_pages and total > 0: if page > total_pages and total > 0:
page = total_pages page = total_pages
query: Select[tuple[PresetModel]] = ( query: Select[tuple[PresetModel]] = select(PresetModel)
select(PresetModel).order_by(*order_by).limit(per_page).offset((page - 1) * per_page) if exclude_defaults:
) query = query.where(PresetModel.default.is_(False))
query = query.order_by(*order_by).limit(per_page).offset((page - 1) * per_page)
result: Result[tuple[PresetModel]] = await session.execute(query) result: Result[tuple[PresetModel]] = await session.execute(query)
return list(result.scalars().all()), total, page, total_pages return list(result.scalars().all()), total, page, total_pages
async def count(self) -> int: async def count(self, exclude_defaults: bool = False) -> int:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(PresetModel)) query = select(func.count()).select_from(PresetModel)
if exclude_defaults:
query = query.where(PresetModel.default.is_(False))
result: Result[tuple[int]] = await session.execute(query)
return int(result.scalar_one()) return int(result.scalar_one())
async def get(self, identifier: int | str) -> PresetModel | None: async def get(self, identifier: int | str) -> PresetModel | None:

View file

@ -26,10 +26,11 @@ async def presets_list(request: Request, encoder: Encoder, repo: PresetsReposito
try: try:
page, per_page = normalize_pagination(request) page, per_page = normalize_pagination(request)
items, total, current_page, total_pages = await repo.list_paginated( items, total, current_page, total_pages = await repo.list_paginated(
page, page=page,
per_page, per_page=per_page,
sort=request.query.get("sort"), sort=request.query.get("sort"),
order=request.query.get("order"), order=request.query.get("order"),
exclude_defaults=bool(request.query.get("exclude_defaults", False)),
) )
except ValueError as exc: except ValueError as exc:
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)

View file

@ -88,6 +88,18 @@ class TestPresetsRepository:
assert [item.name for item in items] == ["gamma", "beta", "alpha"], "Should sort by requested field" assert [item.name for item in items] == ["gamma", "beta", "alpha"], "Should sort by requested field"
@pytest.mark.asyncio
async def test_list_paginated_excludes_defaults(self, repo):
await repo.create({"name": "System Default", "default": True, "priority": 10})
await repo.create({"name": "Custom Preset", "priority": 1})
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=10, exclude_defaults=True)
assert [item.name for item in items] == ["custom_preset"], "Should exclude default presets"
assert total == 1, "Should count only custom presets"
assert page == 1, "Should keep current page when filtered results exist"
assert total_pages == 1, "Should compute pages from the filtered total"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_paginated_supports_multiple_sort_fields(self, repo): async def test_list_paginated_supports_multiple_sort_fields(self, repo):
await repo.create({"name": "Charlie", "priority": 2}) await repo.create({"name": "Charlie", "priority": 2})
@ -153,3 +165,17 @@ class TestPresetRoutes:
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction" assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction"
assert "order" in payload["error"], "Should explain invalid sort direction" assert "order" in payload["error"], "Should explain invalid sort direction"
async def test_list_route_supports_excluding_defaults(self, repo):
await repo.create({"name": "System Default", "default": True, "priority": 10})
await repo.create({"name": "Custom Preset", "priority": 1})
request = MagicMock(spec=Request)
request.query = {"page": "1", "per_page": "10", "exclude_defaults": "true"}
response = await presets_list(request, Encoder(), repo)
payload = json.loads(response.text)
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid default exclusion"
assert [item["name"] for item in payload["items"]] == ["custom_preset"], "Should exclude default presets"
assert payload["pagination"]["total"] == 1, "Should report filtered total"

View file

@ -5,7 +5,7 @@
:title="state.current?.opts.title ?? defaultTitle" :title="state.current?.opts.title ?? defaultTitle"
:dismissible="true" :dismissible="true"
:ui="{ content: 'max-w-lg', body: 'space-y-4', footer: 'justify-end gap-2' }" :ui="{ content: 'max-w-lg', body: 'space-y-4', footer: 'justify-end gap-2' }"
@update:open="(open) => !open && onCancel()" @update:open="(open) => !open && cancel()"
@after:enter="focusInput" @after:enter="focusInput"
> >
<template #body> <template #body>
@ -63,7 +63,7 @@
{{ state.current?.opts.confirmText ?? 'OK' }} {{ state.current?.opts.confirmText ?? 'OK' }}
</UButton> </UButton>
<UButton color="neutral" variant="outline" @click="onCancel"> <UButton color="neutral" variant="outline" @click="cancel">
{{ (state.current?.opts as PromptOptions | ConfirmOptions)?.cancelText ?? 'Cancel' }} {{ (state.current?.opts as PromptOptions | ConfirmOptions)?.cancelText ?? 'Cancel' }}
</UButton> </UButton>
</template> </template>
@ -121,7 +121,6 @@ const focusInput = async () => {
requestAnimationFrame(focusPrimary); requestAnimationFrame(focusPrimary);
}; };
const onCancel = () => cancel();
const onEnter = () => const onEnter = () =>
confirm('confirm' === state.current?.type ? selected.value : localInput.value); confirm('confirm' === state.current?.type ? selected.value : localInput.value);

View file

@ -170,7 +170,7 @@
</div> </div>
<div <div
v-if="show_description && !hasFormatInConfig && get_preset(form.preset)?.description" v-if="show_description && !hasFormatInConfig && findPreset(form.preset)?.description"
class="max-h-36 overflow-auto rounded-md border border-default bg-muted/30 px-3 py-2 text-sm text-toned" class="max-h-36 overflow-auto rounded-md border border-default bg-muted/30 px-3 py-2 text-sm text-toned"
> >
<button <button
@ -180,7 +180,7 @@
> >
<span class="inline-flex items-start gap-2"> <span class="inline-flex items-start gap-2">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" /> <UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="is-ellipsis">{{ get_preset(form.preset)?.description }}</span> <span class="is-ellipsis">{{ findPreset(form.preset)?.description }}</span>
</span> </span>
</button> </button>
</div> </div>
@ -1124,7 +1124,6 @@ const hasFormatInConfig = computed(
(): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/), (): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/),
); );
const get_preset = (name: string | undefined) => findPreset(name);
const expand_description = (e: Event) => const expand_description = (e: Event) =>
toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']); toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']);

View file

@ -367,13 +367,12 @@ const props = defineProps<{
reference?: number | null; reference?: number | null;
preset: Partial<Preset>; preset: Partial<Preset>;
addInProgress?: boolean; addInProgress?: boolean;
presets?: Preset[];
}>(); }>();
const config = useYtpConfig(); const config = useYtpConfig();
const toast = useNotification(); const toast = useNotification();
const dialog = useDialog(); const dialog = useDialog();
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets); const { presets, findPreset, selectItems } = usePresetOptions();
const form = reactive<Preset>({ const form = reactive<Preset>({
name: '', name: '',

View file

@ -129,6 +129,7 @@ const removePreset = (id: number) => {
const loadPresets = async ( const loadPresets = async (
page: number = 1, page: number = 1,
perPage: number | undefined = undefined, perPage: number | undefined = undefined,
options: { excludeDefaults?: boolean } = {},
): Promise<void> => { ): Promise<void> => {
isLoading.value = true; isLoading.value = true;
try { try {
@ -136,6 +137,10 @@ const loadPresets = async (
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}`; url += `&per_page=${perPage}`;
} }
if (options.excludeDefaults) {
url += '&exclude_defaults=true';
}
const response = await request(url); const response = await request(url);
await ensureSuccess(response); await ensureSuccess(response);

View file

@ -63,7 +63,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadContent(page)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -122,7 +122,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -447,7 +447,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -648,14 +648,6 @@ const loadContent = async (pageNumber = 1): Promise<void> => {
await syncPageQuery(pageNumber); await syncPageQuery(pageNumber);
}; };
const reloadContent = async (): Promise<void> => {
await loadContent(page.value);
};
const navigatePage = async (newPage: number): Promise<void> => {
await loadContent(newPage);
};
const resetEditor = (): void => { const resetEditor = (): void => {
item.value = {}; item.value = {};
itemRef.value = null; itemRef.value = null;

View file

@ -63,7 +63,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadContent(page)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -122,7 +122,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -399,7 +399,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -567,14 +567,6 @@ const loadContent = async (pageNumber = 1): Promise<void> => {
await syncPageQuery(pageNumber); await syncPageQuery(pageNumber);
}; };
const reloadContent = async (): Promise<void> => {
await loadContent(page.value);
};
const navigatePage = async (newPage: number): Promise<void> => {
await loadContent(newPage);
};
const resetEditor = (): void => { const resetEditor = (): void => {
item.value = {}; item.value = {};
itemRef.value = null; itemRef.value = null;

View file

@ -76,7 +76,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadContent(page)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -135,7 +135,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -497,7 +497,7 @@
:disabled="isLoading" :disabled="isLoading"
show-edges show-edges
:sibling-count="0" :sibling-count="0"
@update:page="navigatePage" @update:page="loadContent"
size="sm" size="sm"
/> />
</div> </div>
@ -706,14 +706,6 @@ const loadContent = async (pageNumber = page.value): Promise<void> => {
await notificationsStore.loadNotifications(pageNumber); await notificationsStore.loadNotifications(pageNumber);
}; };
const reloadContent = async (): Promise<void> => {
await loadContent(page.value);
};
const navigatePage = async (newPage: number): Promise<void> => {
await loadContent(newPage);
};
const resetEditor = (): void => { const resetEditor = (): void => {
target.value = defaultState(); target.value = defaultState();
targetRef.value = undefined; targetRef.value = undefined;

View file

@ -23,7 +23,7 @@
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end"> <div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton <UButton
v-if="presetsNoDefault.length > 0" v-if="presets.length > 0"
color="neutral" color="neutral"
:variant="showFilter ? 'soft' : 'outline'" :variant="showFilter ? 'soft' : 'outline'"
size="sm" size="sm"
@ -62,13 +62,13 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadContent(page)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
<UInput <UInput
v-if="showFilter && presetsNoDefault.length > 0" v-if="showFilter && presets.length > 0"
id="filter" id="filter"
ref="filterInput" ref="filterInput"
v-model="query" v-model="query"
@ -112,6 +112,18 @@
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
</div> </div>
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="loadContent"
size="sm"
/>
</div> </div>
<div <div
@ -416,6 +428,22 @@
description="There are no custom defined presets." description="There are no custom defined presets."
/> />
<div
v-if="filteredPresets.length > 0 && !query && paging?.total_pages > 1"
class="flex justify-end"
>
<UPagination
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="loadContent"
size="sm"
/>
</div>
<UAlert v-if="!query && presets.length > 0" color="info" variant="soft"> <UAlert v-if="!query && presets.length > 0" color="info" variant="soft">
<template #description> <template #description>
<ul class="list-disc space-y-2 pl-5 text-sm text-default"> <ul class="list-disc space-y-2 pl-5 text-sm text-default">
@ -443,7 +471,6 @@
:addInProgress="editor.addInProgress.value" :addInProgress="editor.addInProgress.value"
:reference="editor.reference.value" :reference="editor.reference.value"
:preset="editor.preset.value" :preset="editor.preset.value"
:presets="presets"
@cancel="() => void editor.requestClose()" @cancel="() => void editor.requestClose()"
@dirty-change="(dirty) => (editor.dirty.value = dirty)" @dirty-change="(dirty) => (editor.dirty.value = dirty)"
@submit="editor.submit" @submit="editor.submit"
@ -470,6 +497,8 @@ const config = useYtpConfig();
const box = useConfirm(); const box = useConfirm();
const editor = usePresetEditor(); const editor = usePresetEditor();
const pageShell = requirePageShell('presets'); const pageShell = requirePageShell('presets');
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
@ -481,21 +510,19 @@ const showFilter = ref(false);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null); const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const selectedIds = ref<number[]>([]); const selectedIds = ref<number[]>([]);
const massDelete = ref(false); const massDelete = ref(false);
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]); const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
const paging = presetsStore.pagination;
const isLoading = presetsStore.isLoading; const isLoading = presetsStore.isLoading;
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default));
const filteredPresets = computed<PresetWithUI[]>(() => { const filteredPresets = computed<PresetWithUI[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
if (!normalizedQuery) { if (!normalizedQuery) {
return presetsNoDefault.value; return presets.value;
} }
return presetsNoDefault.value.filter((item) => return presets.value.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
deepIncludes(item, normalizedQuery, new WeakSet()),
);
}); });
const selectablePresetIds = computed(() => const selectablePresetIds = computed(() =>
@ -525,6 +552,19 @@ const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
], ],
]); ]);
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = paging.value.total_pages;
const nextQuery = { ...route.query };
if (totalPages > 1) {
nextQuery.page = String(pageNumber);
} else {
delete nextQuery.page;
}
await router.replace({ query: nextQuery });
};
watch(showFilter, (value) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.value = ''; query.value = '';
@ -553,8 +593,11 @@ const toggleFilterPanel = async (): Promise<void> => {
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true }); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const reloadContent = async (): Promise<void> => { const loadContent = async (pageNumber = 1): Promise<void> => {
await presetsStore.loadPresets(1, 1000); page.value = pageNumber;
await presetsStore.loadPresets(pageNumber, undefined, { excludeDefaults: true });
await nextTick();
await syncPageQuery(pageNumber);
}; };
const toggleMasterSelection = (): void => { const toggleMasterSelection = (): void => {
@ -600,15 +643,20 @@ const deleteSelected = async (): Promise<void> => {
massDelete.value = true; massDelete.value = true;
for (const item of itemsToDelete) { try {
if (!item.id) { for (const item of itemsToDelete) {
continue; if (!item.id) {
continue;
}
await presetsStore.deletePreset(item.id);
} }
await presetsStore.deletePreset(item.id); } finally {
selectedIds.value = [];
massDelete.value = false;
} }
selectedIds.value = []; await loadContent(page.value);
massDelete.value = false;
}; };
const deleteItem = async (item: Preset): Promise<void> => { const deleteItem = async (item: Preset): Promise<void> => {
@ -644,5 +692,5 @@ const calcPath = (path?: string): string => {
return path ? location + '/' + sTrim(path, '/') : location; return path ? location + '/' + sTrim(path, '/') : location;
}; };
onMounted(async () => await reloadContent()); onMounted(async () => await loadContent(page.value));
</script> </script>

View file

@ -71,7 +71,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadDefinitions(1, 1000)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -642,10 +642,6 @@ const toggleFilterPanel = async (): Promise<void> => {
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true }); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const reloadContent = async (): Promise<void> => {
await loadDefinitions(1, 1000);
};
const toggleDisplayStyle = (): void => { const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; display_style.value = display_style.value === 'list' ? 'grid' : 'list';
}; };
@ -837,7 +833,7 @@ const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> =
onMounted(async () => { onMounted(async () => {
if (!definitions.value.length) { if (!definitions.value.length) {
await reloadContent(); await loadDefinitions(1, 1000);
} }
}); });
</script> </script>

View file

@ -62,7 +62,7 @@
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
:loading="isLoading" :loading="isLoading"
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void loadContent(page)"
> >
<span>Reload</span> <span>Reload</span>
</UButton> </UButton>
@ -113,7 +113,17 @@
</UDropdownMenu> </UDropdownMenu>
</div> </div>
<div class="text-xs text-toned">{{ filteredTasks.length }} displayed</div> <UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="loadContent"
size="sm"
/>
</div> </div>
<div <div
@ -628,6 +638,19 @@
description="There are no tasks defined yet. Click the New Task button to create your first automated download task." description="There are no tasks defined yet. Click the New Task button to create your first automated download task."
/> />
<div v-if="filteredTasks.length > 0 && paging?.total_pages > 1" class="flex justify-end">
<UPagination
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="loadContent"
size="sm"
/>
</div>
<UAlert v-if="tasks.length > 0" color="info" variant="soft"> <UAlert v-if="tasks.length > 0" color="info" variant="soft">
<template #description> <template #description>
<ul class="list-disc space-y-2 pl-5 text-sm text-default"> <ul class="list-disc space-y-2 pl-5 text-sm text-default">
@ -722,6 +745,8 @@ const config = useYtpConfig();
const socket = useAppSocket(); const socket = useAppSocket();
const stateStore = useQueueState(); const stateStore = useQueueState();
const pageShell = requirePageShell('tasks'); const pageShell = requirePageShell('tasks');
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const sessionCache = useSessionCache(); const sessionCache = useSessionCache();
const { toggleExpand, expandClass } = useExpandableMeta(); const { toggleExpand, expandClass } = useExpandableMeta();
@ -731,6 +756,7 @@ const isMobile = useMediaQuery({ maxWidth: 639 });
const tasksComposable = useTasks(); const tasksComposable = useTasks();
const { const {
tasks, tasks,
pagination: paging,
isLoading, isLoading,
addInProgress, addInProgress,
isTaskInProgress, isTaskInProgress,
@ -762,6 +788,7 @@ const inspectTask = ref<Task | null>(null);
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null); const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
const CACHE_KEY = 'tasks:handler_support'; const CACHE_KEY = 'tasks:handler_support';
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {}); const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {});
@ -861,6 +888,19 @@ watch(
{ immediate: true }, { immediate: true },
); );
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = tasksComposable.pagination.value.total_pages;
const nextQuery = { ...route.query };
if (totalPages > 1) {
nextQuery.page = String(pageNumber);
} else {
delete nextQuery.page;
}
await router.replace({ query: nextQuery });
};
const toggleFilterPanel = async (): Promise<void> => { const toggleFilterPanel = async (): Promise<void> => {
showFilter.value = !showFilter.value; showFilter.value = !showFilter.value;
if (!showFilter.value) { if (!showFilter.value) {
@ -941,9 +981,15 @@ const willTaskBeProcessed = (item: Task): boolean => {
return hasTimer || hasHandler; return hasTimer || hasHandler;
}; };
const reloadContent = async (fromMounted: boolean = false) => { const loadContent = async (pageNumber = page.value, fromMounted: boolean = false) => {
page.value = pageNumber;
try { try {
await tasksComposable.loadTasks(); await tasksComposable.loadTasks(pageNumber);
page.value = tasksComposable.pagination.value.page;
await nextTick();
await syncPageQuery(page.value);
if (tasks.value.length > 0) { if (tasks.value.length > 0) {
cleanStaleCache(tasks.value); cleanStaleCache(tasks.value);
@ -1400,7 +1446,7 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
]; ];
onMounted(async () => { onMounted(async () => {
await reloadContent(true); await loadContent(page.value, true);
}); });
onBeforeUnmount(() => socket.off('item_status', statusHandler)); onBeforeUnmount(() => socket.off('item_status', statusHandler));

View file

@ -163,8 +163,14 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
body {
padding-top: calc(1rem + env(safe-area-inset-top));
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.loading-card { .loading-card {
padding: 1.3rem; padding: 1.3rem;
transform: translateY(clamp(-2.75rem, -7vh, -1.5rem));
} }
.brand { .brand {

View file

@ -104,6 +104,26 @@ describe('usePresets', () => {
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
it('requests custom presets without defaults when asked', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [mockPreset],
pagination: mockPagination,
},
}),
)
const presets = usePresets()
await presets.loadPresets(2, 25, { excludeDefaults: true })
expect(requestSpy).toHaveBeenCalledWith('/api/presets/?page=2&per_page=25&exclude_defaults=true')
requestSpy.mockRestore()
})
it('sorts presets by priority then name', async () => { it('sorts presets by priority then name', async () => {
const items = [ const items = [
{ ...mockPreset, id: 1, name: 'B', priority: 2 }, { ...mockPreset, id: 1, name: 'B', priority: 2 },
@ -127,12 +147,12 @@ describe('usePresets', () => {
await presets.loadPresets() await presets.loadPresets()
const sorted = presets.presets.value const sorted = presets.presets.value
expect(sorted[0].priority).toBe(2) expect(sorted[0]!.priority).toBe(2)
expect(sorted[0].name).toBe('A') expect(sorted[0]!.name).toBe('A')
expect(sorted[1].priority).toBe(2) expect(sorted[1]!.priority).toBe(2)
expect(sorted[1].name).toBe('B') expect(sorted[1]!.name).toBe('B')
expect(sorted[2].priority).toBe(1) expect(sorted[2]!.priority).toBe(1)
expect(sorted[2].name).toBe('C') expect(sorted[2]!.name).toBe('C')
requestSpy.mockRestore() requestSpy.mockRestore()
}) })
@ -219,7 +239,7 @@ describe('usePresets', () => {
const presets = usePresets() const presets = usePresets()
await presets.updatePreset(1, { ...mockPreset, default: true }) await presets.updatePreset(1, { ...mockPreset, default: true })
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body) const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
expect(requestBody.id).toBeUndefined() expect(requestBody.id).toBeUndefined()
expect(requestBody.default).toBe(false) expect(requestBody.default).toBe(false)
requestSpy.mockRestore() requestSpy.mockRestore()
@ -257,7 +277,7 @@ describe('usePresets', () => {
const presets = usePresets() const presets = usePresets()
await presets.patchPreset(1, { id: 10, default: true }) await presets.patchPreset(1, { id: 10, default: true })
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body) const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
expect(requestBody.id).toBeUndefined() expect(requestBody.id).toBeUndefined()
expect(requestBody.default).toBe(false) expect(requestBody.default).toBe(false)
requestSpy.mockRestore() requestSpy.mockRestore()

View file

@ -1,20 +1,18 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
const disableOpacityMock = mock(() => {}); const disableOpacityMock = mock(() => {});
const enableOpacityMock = mock(() => {}); const enableOpacityMock = mock(() => {});
const syncOpacityMock = mock(() => {}); const syncOpacityMock = mock(() => {});
mock.module('~/utils', () => ({
disableOpacity: disableOpacityMock,
enableOpacity: enableOpacityMock,
syncOpacity: syncOpacityMock,
}));
(globalThis as typeof globalThis & { defineNuxtPlugin?: (setup: () => void) => () => void }).defineNuxtPlugin = (globalThis as typeof globalThis & { defineNuxtPlugin?: (setup: () => void) => () => void }).defineNuxtPlugin =
(setup) => setup; (setup) => setup;
let utils: Awaited<typeof import('~/utils/index')>;
let plugin: Awaited<typeof import('../../app/plugins/modal-opacity.client.ts')>['default']; let plugin: Awaited<typeof import('../../app/plugins/modal-opacity.client.ts')>['default'];
let started = false; let started = false;
let disableOpacitySpy: ReturnType<typeof spyOn>;
let enableOpacitySpy: ReturnType<typeof spyOn>;
let syncOpacitySpy: ReturnType<typeof spyOn>;
const flushMutations = async (): Promise<void> => { const flushMutations = async (): Promise<void> => {
await Promise.resolve(); await Promise.resolve();
@ -38,9 +36,19 @@ const startPlugin = (): void => {
}; };
beforeAll(async () => { beforeAll(async () => {
utils = await import('~/utils/index');
disableOpacitySpy = spyOn(utils, 'disableOpacity').mockImplementation(disableOpacityMock);
enableOpacitySpy = spyOn(utils, 'enableOpacity').mockImplementation(enableOpacityMock);
syncOpacitySpy = spyOn(utils, 'syncOpacity').mockImplementation(syncOpacityMock);
plugin = (await import('../../app/plugins/modal-opacity.client.ts')).default; plugin = (await import('../../app/plugins/modal-opacity.client.ts')).default;
}); });
afterAll(() => {
disableOpacitySpy.mockRestore();
enableOpacitySpy.mockRestore();
syncOpacitySpy.mockRestore();
});
beforeEach(() => { beforeEach(() => {
globalThis.MutationObserver = window.MutationObserver; globalThis.MutationObserver = window.MutationObserver;
document.body.innerHTML = ''; document.body.innerHTML = '';