feat: add filtering to all entity list pages
- Tracking configs: filter by name + provider type - Template configs: filter by name + provider type - Command configs: filter by name + provider type - Notification trackers: filter by name + provider - Command trackers: filter by name + provider - Targets: filter by name (type filtering already existed) - Nav badge counts include system-owned entities (user_id=0) - Shows "no items match filter" vs "no items yet" empty states
This commit is contained in:
@@ -748,7 +748,11 @@
|
||||
"syntaxError": "Syntax error",
|
||||
"undefinedVar": "Unknown variable",
|
||||
"line": "line",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"filterByName": "Filter by name...",
|
||||
"allTypes": "All types",
|
||||
"allProviders": "All providers",
|
||||
"noFilterResults": "No items match the current filter."
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Page not found",
|
||||
|
||||
@@ -748,7 +748,11 @@
|
||||
"syntaxError": "Ошибка синтаксиса",
|
||||
"undefinedVar": "Неизвестная переменная",
|
||||
"line": "строка",
|
||||
"add": "Добавить"
|
||||
"add": "Добавить",
|
||||
"filterByName": "Фильтр по имени...",
|
||||
"allTypes": "Все типы",
|
||||
"allProviders": "Все провайдеры",
|
||||
"noFilterResults": "Нет элементов, соответствующих фильтру."
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Страница не найдена",
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
return tpl?.name || `#${id}`;
|
||||
}
|
||||
|
||||
let configs = $derived(commandConfigsCache.items);
|
||||
let allCmdConfigs = $derived(commandConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
let configs = $derived(allCmdConfigs.filter(c =>
|
||||
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
||||
(!filterType || c.provider_type === filterType)
|
||||
));
|
||||
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
|
||||
const templateItems = $derived(cmdTemplateConfigs
|
||||
.filter((c: any) => c.provider_type === form.provider_type)
|
||||
@@ -229,10 +235,27 @@
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
{#if !showForm && allCmdConfigs.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<select bind:value={filterType}
|
||||
class="px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('common.allTypes')}</option>
|
||||
<option value="immich">Immich</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allCmdConfigs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as cfg}
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let allCmdTrackers = $state<any[]>([]);
|
||||
let filterText = $state('');
|
||||
let filterProviderId = $state(0);
|
||||
let trackers = $derived(allCmdTrackers.filter((t: any) =>
|
||||
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
||||
(!filterProviderId || t.provider_id === filterProviderId)
|
||||
));
|
||||
let providers = $derived(providersCache.items);
|
||||
let commandConfigs = $derived(commandConfigsCache.items);
|
||||
let telegramBots = $derived(telegramBotsCache.items);
|
||||
@@ -60,7 +66,7 @@
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[trackers] = await Promise.all([
|
||||
[allCmdTrackers] = await Promise.all([
|
||||
api('/command-trackers'),
|
||||
providersCache.fetch(), commandConfigsCache.fetch(),
|
||||
telegramBotsCache.fetch(),
|
||||
@@ -212,10 +218,28 @@
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
{#if !showForm && allCmdTrackers.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<select bind:value={filterProviderId}
|
||||
class="px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>{t('common.allProviders')}</option>
|
||||
{#each providers as p}
|
||||
<option value={p.id}>{p.name} ({p.type})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allCmdTrackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as trk}
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let notificationTrackers = $state<Tracker[]>([]);
|
||||
let allNotificationTrackers = $state<Tracker[]>([]);
|
||||
let filterText = $state('');
|
||||
let filterProviderId = $state(0);
|
||||
let notificationTrackers = $derived(allNotificationTrackers.filter(t =>
|
||||
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
||||
(!filterProviderId || t.provider_id === filterProviderId)
|
||||
));
|
||||
let providers = $derived(providersCache.items);
|
||||
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })));
|
||||
let targets = $derived(targetsCache.items);
|
||||
@@ -91,7 +97,7 @@
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[notificationTrackers] = await Promise.all([
|
||||
[allNotificationTrackers] = await Promise.all([
|
||||
api('/notification-trackers'),
|
||||
providersCache.fetch(), targetsCache.fetch(),
|
||||
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||
@@ -366,10 +372,29 @@
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
{#if notificationTrackers.length === 0 && !showForm}
|
||||
|
||||
{#if !showForm && allNotificationTrackers.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<select bind:value={filterProviderId}
|
||||
class="px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>{t('common.allProviders')}</option>
|
||||
{#each providers as p}
|
||||
<option value={p.id}>{p.name} ({p.type})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allNotificationTrackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else if notificationTrackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each notificationTrackers as tracker}
|
||||
|
||||
@@ -93,7 +93,11 @@
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
|
||||
let filterText = $state('');
|
||||
let targets = $derived(allTargets.filter(t =>
|
||||
(!activeType || t.type === activeType) &&
|
||||
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase()))
|
||||
));
|
||||
let telegramBots = $derived(telegramBotsCache.items);
|
||||
let emailBots = $derived(emailBotsCache.items);
|
||||
let matrixBots = $derived(matrixBotsCache.items);
|
||||
@@ -479,10 +483,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if targets.length === 0 && !showForm}
|
||||
{#if !showForm && allTargets.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allTargets.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiTarget" message={t('targets.noTargets')} />
|
||||
</Card>
|
||||
{:else if targets.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each targets as target (target.id)}
|
||||
@@ -547,11 +562,14 @@
|
||||
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
||||
{#if target.type === 'telegram'}
|
||||
{@const botId = target.config?.bot_id}
|
||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||
{@const chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
|
||||
value: c.chat_id,
|
||||
label: c.title || c.username || c.chat_id,
|
||||
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
||||
desc: `${c.type} · ${c.chat_id}`,
|
||||
disabled: existingKeys.has(c.chat_id),
|
||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||
}))}
|
||||
{#if chatItems.length > 0}
|
||||
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { TemplateConfig } from '$lib/types';
|
||||
|
||||
let configs = $derived(templateConfigsCache.items);
|
||||
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
let configs = $derived(allTemplateConfigs.filter(c =>
|
||||
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
||||
(!filterType || c.provider_type === filterType)
|
||||
));
|
||||
let loaded = $state(false);
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
@@ -337,10 +343,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
{#if !showForm && allTemplateConfigs.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<select bind:value={filterType}
|
||||
class="px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('common.allTypes')}</option>
|
||||
<option value="immich">Immich</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allTemplateConfigs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFileDocumentEdit" message={t('templateConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { TrackingConfig } from '$lib/types';
|
||||
|
||||
let configs = $derived(trackingConfigsCache.items);
|
||||
let allConfigs = $derived(trackingConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
let configs = $derived(allConfigs.filter(c =>
|
||||
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
||||
(!filterType || c.provider_type === filterType)
|
||||
));
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -228,10 +234,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
{#if !showForm && allConfigs.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<select bind:value={filterType}
|
||||
class="px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('common.allTypes')}</option>
|
||||
<option value="immich">Immich</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allConfigs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiCog" message={t('trackingConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
|
||||
@@ -132,10 +132,12 @@ async def get_nav_counts(
|
||||
)).one()
|
||||
counts[key] = count
|
||||
|
||||
# System-owned templates (user_id=0) count as well
|
||||
# System-owned entities (user_id=0) count as well
|
||||
for model, key in [
|
||||
(TemplateConfig, "template_configs"),
|
||||
(CommandTemplateConfig, "command_template_configs"),
|
||||
(TrackingConfig, "tracking_configs"),
|
||||
(CommandConfig, "command_configs"),
|
||||
]:
|
||||
system_count = (await session.exec(
|
||||
select(func.count()).select_from(model).where(model.user_id == 0)
|
||||
|
||||
Reference in New Issue
Block a user