feat: make my apps searchable and sortable

This commit is contained in:
Elias Schneider
2026-10-07 21:25:22 +02:00
parent 27709be5cf
commit 9c0c304d59
10 changed files with 534 additions and 180 deletions
@@ -0,0 +1,82 @@
<script lang="ts">
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { PaginationResponse } from '#lib/types/list-request.type.ts';
import { cn } from '#lib/utils/style.ts';
let {
pagination,
onPageChange,
onPageSizeChange,
pageSizes = [20, 50, 100],
hideWhenSinglePage = false,
class: className
}: {
pagination?: PaginationResponse;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
pageSizes?: number[];
hideWhenSinglePage?: boolean;
class?: string;
} = $props();
// The controls are useless when every item already fits on a page of the smallest size
const hidden = $derived(
hideWhenSinglePage && (pagination?.totalItems ?? 0) <= Math.min(...pageSizes)
);
</script>
{#if !hidden}
<div
class={cn(
'mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row',
className
)}
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={pagination?.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20" aria-label={m.items_per_page()}>
{pagination?.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each pageSizes as size (size)}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={pagination?.totalItems || 0}
perPage={pagination?.itemsPerPage}
{onPageChange}
page={pagination?.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={pagination?.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
@@ -2,9 +2,8 @@
import { goto } from '$app/navigation';
import { page as currentPage } from '$app/state';
import ActionsMenu from '#lib/components/actions-menu.svelte';
import ListPagination from '#lib/components/list-pagination.svelte';
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
import * as Table from '#lib/components/ui/table/index.js';
import Empty from '#lib/icons/empty.svelte';
import { m } from '#lib/paraglide/messages.js';
@@ -317,50 +316,10 @@
</div>
{/if}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size (size)}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<ListPagination
pagination={items?.pagination}
pageSizes={availablePageSizes}
{onPageChange}
{onPageSizeChange}
/>
{/if}
+171 -105
View File
@@ -1,23 +1,35 @@
<script lang="ts">
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import ListPagination from '#lib/components/list-pagination.svelte';
import { Button, buttonVariants } from '#lib/components/ui/button/index.ts';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import * as Empty from '#lib/components/ui/empty/index.ts';
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as InputGroup from '#lib/components/ui/input-group/index.js';
import { Separator } from '#lib/components/ui/separator/index.ts';
import { m } from '#lib/paraglide/messages.js';
import OIDCService from '#lib/services/oidc-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import type {
AccessibleOidcClient,
AuthorizedOidcClient,
OidcClientMetaData
} from '#lib/types/oidc.type.ts';
import { debounced } from '#lib/utils/debounce-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { cn } from '#lib/utils/style.ts';
import { ChevronDown, LayoutDashboard } from '@lucide/svelte';
import { ArrowUpDown, ChevronDown, LayoutDashboard, Search, SearchX } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { prefersReducedMotion } from 'svelte/motion';
import { fade, slide } from 'svelte/transition';
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
import {
getMyAppsPreferences,
myAppsPageSizes,
myAppsPreferences,
myAppsSortOptions,
type MyAppsSort
} from './my-apps-preferences.svelte.ts';
let { data } = $props();
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
@@ -29,6 +41,8 @@
data.authorizedClientRequestOptions
);
let showAllApps = $state(false);
let searchValue = $state('');
let sort: MyAppsSort = $state(getMyAppsPreferences().sort);
const hiddenAuthorizedClients = $derived(
authorizedClientsWithoutLaunchURL.data.map(({ client, lastUsedAt }) => ({
...client,
@@ -37,16 +51,70 @@
);
const oidcService = new OIDCService();
const animationDuration = $derived(
$appConfigStore.disableAnimations || prefersReducedMotion.current ? 0 : 200
);
const sortLabels: Record<MyAppsSort, () => string> = {
recentlyUsed: m.recently_used,
nameAsc: m.name_a_to_z,
nameDesc: m.name_z_to_a
};
// Only unfiltered results tell whether the user has any apps, so the toolbar never unmounts while a search is being typed or cleared
let hasAnyApps = $state(
data.clients.pagination.totalItems +
data.authorizedClientsWithoutLaunchURL.pagination.totalItems >
0
);
async function refreshClients() {
[clients, authorizedClientsWithoutLaunchURL] = await Promise.all([
oidcService.listOwnAccessibleClients(requestOptions),
oidcService.listOwnAuthorizedClients(authorizedClientRequestOptions)
]);
if (!requestOptions.search) {
hasAnyApps =
clients.pagination.totalItems + authorizedClientsWithoutLaunchURL.pagination.totalItems > 0;
}
if (authorizedClientsWithoutLaunchURL.pagination.totalItems === 0) {
showAllApps = false;
}
}
// Both grids share the search, sort and page size, so any change reloads both from their first page
async function reloadFromFirstPage({
search = searchValue,
limit = getMyAppsPreferences().paginationLimit
}: { search?: string; limit?: number } = {}) {
for (const options of [requestOptions, authorizedClientRequestOptions]) {
options.search = search || undefined;
options.sort = { ...myAppsSortOptions[sort] };
options.pagination = { page: 1, limit };
}
try {
await refreshClients();
// The search is applied together with its results so the empty state never describes stale results
searchValue = search;
} catch (e) {
axiosErrorToast(e);
}
}
const onSearch = debounced((search: string) => reloadFromFirstPage({ search }), 300);
async function onSortChange(value: string) {
sort = value as MyAppsSort;
myAppsPreferences.current.sort = sort;
await reloadFromFirstPage();
}
async function onPageSizeChange(size: number) {
myAppsPreferences.current.paginationLimit = size;
await reloadFromFirstPage({ limit: size });
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
clients = await oidcService.listOwnAccessibleClients(requestOptions);
@@ -94,23 +162,66 @@
<title>{m.my_apps()}</title>
</svelte:head>
<div>
<div>
<h1 class="mb-5 flex items-center gap-2 text-2xl font-bold">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<h1 class="flex items-center gap-2 text-2xl font-bold">
<LayoutDashboard class="text-primary/80 size-6" />
{m.my_apps()}
</h1>
{#if hasAnyApps}
<div class="flex w-full items-center gap-2 sm:w-auto">
<InputGroup.Root class="w-full sm:w-64">
<InputGroup.Input
value={searchValue}
placeholder={m.search()}
aria-label={m.search_apps()}
type="search"
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
/>
<InputGroup.Addon>
<Search />
</InputGroup.Addon>
</InputGroup.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'outline', size: 'icon', class: 'shrink-0' })}
aria-label={m.sort_by()}
title={m.sort_by()}
>
<ArrowUpDown />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Label>{m.sort_by()}</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={sort} onValueChange={onSortChange}>
{#each Object.keys(myAppsSortOptions) as MyAppsSort[] as option (option)}
<DropdownMenu.RadioItem value={option}
>{sortLabels[option]()}</DropdownMenu.RadioItem
>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
{#if clients.data.length === 0 && !showAllApps}
<Empty.Root class="mt-20">
<Empty.Header>
<Empty.Media variant="icon">
<LayoutDashboard />
</Empty.Media>
<Empty.Title>{m.no_apps_available()}</Empty.Title>
<Empty.Description>
{m.contact_your_administrator_for_app_access()}
</Empty.Description>
{#if searchValue}
<Empty.Media variant="icon">
<SearchX />
</Empty.Media>
<Empty.Title>{m.no_apps_match_your_search()}</Empty.Title>
<Empty.Description>{m.try_a_different_search_term()}</Empty.Description>
{:else}
<Empty.Media variant="icon">
<LayoutDashboard />
</Empty.Media>
<Empty.Title>{m.no_apps_available()}</Empty.Title>
<Empty.Description>
{m.contact_your_administrator_for_app_access()}
</Empty.Description>
{/if}
</Empty.Header>
{#if authorizedClientsWithoutLaunchURL.pagination.totalItems > 0}
<Empty.Content>
@@ -122,108 +233,63 @@
</Empty.Root>
{:else}
{#if clients.data.length > 0}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
>
{#each clients.data as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if clients.data.length === 2}
<div></div>
{/if}
<div class="grid">
{#key clients}
<div
class="col-start-1 row-start-1 grid gap-3 self-start"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
transition:fade={{ duration: animationDuration }}
>
{#each clients.data as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if clients.data.length === 2}
<div></div>
{/if}
</div>
{/key}
</div>
{/if}
{#if clients.pagination.totalPages > 1}
<div class="mt-5 flex items-center justify-center">
<Pagination.Root
class="mx-0 w-auto"
count={clients.pagination.totalItems}
perPage={clients.pagination.itemsPerPage}
{onPageChange}
page={clients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link
{page}
isActive={clients.pagination.currentPage === page.value}
>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
<ListPagination
pagination={clients.pagination}
pageSizes={myAppsPageSizes}
{onPageChange}
{onPageSizeChange}
hideWhenSinglePage
/>
{#if showAllApps}
<div transition:slide={{ duration: 200 }}>
<div transition:slide={{ duration: animationDuration }}>
{#if clients.data.length > 0}
<Separator class="my-8" />
{/if}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
>
{#each hiddenAuthorizedClients as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if hiddenAuthorizedClients.length === 2}
<div></div>
{/if}
<div class="grid">
{#key authorizedClientsWithoutLaunchURL}
<div
class="col-start-1 row-start-1 grid gap-3 self-start"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
transition:fade={{ duration: animationDuration }}
>
{#each hiddenAuthorizedClients as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if hiddenAuthorizedClients.length === 2}
<div></div>
{/if}
</div>
{/key}
</div>
{#if authorizedClientsWithoutLaunchURL.pagination.totalPages > 1}
<div class="mt-5 flex items-center justify-center">
<Pagination.Root
class="mx-0 w-auto"
count={authorizedClientsWithoutLaunchURL.pagination.totalItems}
perPage={authorizedClientsWithoutLaunchURL.pagination.itemsPerPage}
onPageChange={onAuthorizedClientPageChange}
page={authorizedClientsWithoutLaunchURL.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link
{page}
isActive={authorizedClientsWithoutLaunchURL.pagination.currentPage ===
page.value}
>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
<ListPagination
pagination={authorizedClientsWithoutLaunchURL.pagination}
pageSizes={myAppsPageSizes}
onPageChange={onAuthorizedClientPageChange}
{onPageSizeChange}
hideWhenSinglePage
/>
</div>
{/if}
{/if}
+6 -10
View File
@@ -1,19 +1,18 @@
import OIDCService from '#lib/services/oidc-service.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import type { PageLoad } from './$types';
import { getMyAppsPreferences, myAppsSortOptions } from './my-apps-preferences.svelte.ts';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
const preferences = getMyAppsPreferences();
const appRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
limit: preferences.paginationLimit
},
sort: { ...myAppsSortOptions[preferences.sort] },
filters: {
hasLaunchURL: [true]
}
@@ -22,12 +21,9 @@ export const load: PageLoad = async () => {
const authorizedClientRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
limit: preferences.paginationLimit
},
sort: { ...myAppsSortOptions[preferences.sort] },
filters: {
hasLaunchURL: [false]
}
@@ -0,0 +1,38 @@
import type { SortRequest } from '#lib/types/list-request.type.ts';
import { PersistedState } from 'runed';
export const myAppsSortOptions = {
recentlyUsed: { column: 'lastUsedAt', direction: 'desc' },
nameAsc: { column: 'name', direction: 'asc' },
nameDesc: { column: 'name', direction: 'desc' }
} satisfies Record<string, SortRequest>;
export type MyAppsSort = keyof typeof myAppsSortOptions;
export const myAppsPageSizes = [20, 50, 100];
type MyAppsPreferences = {
sort: MyAppsSort;
paginationLimit: number;
};
const defaultPreferences: MyAppsPreferences = {
sort: 'recentlyUsed',
paginationLimit: myAppsPageSizes[0]
};
export const myAppsPreferences = new PersistedState<MyAppsPreferences>(
'my-apps-preferences',
defaultPreferences
);
// Stored values can be stale or edited by hand, so unknown ones fall back to the defaults
export function getMyAppsPreferences(): MyAppsPreferences {
const { sort, paginationLimit } = myAppsPreferences.current;
return {
sort: sort in myAppsSortOptions ? sort : defaultPreferences.sort,
paginationLimit: myAppsPageSizes.includes(paginationLimit)
? paginationLimit
: defaultPreferences.paginationLimit
};
}