mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-08 18:59:10 +02:00
feat: make my apps searchable and sortable
This commit is contained in:
@@ -470,9 +470,10 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error {
|
||||
// @Summary List authorized clients for current user
|
||||
// @Description Get a paginated list of OIDC clients that the current user has authorized
|
||||
// @Tags OIDC
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
@@ -488,9 +489,10 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error
|
||||
// @Description Get a paginated list of OIDC clients that a specific user has authorized
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "User ID"
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
@@ -502,9 +504,10 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error {
|
||||
}
|
||||
|
||||
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) error {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
|
||||
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, searchTerm, listRequestOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -548,20 +551,22 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) er
|
||||
// @Summary List accessible OIDC clients for current user
|
||||
// @Description Get a list of OIDC clients that the current user can access
|
||||
// @Tags OIDC
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
|
||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||
// @Failure default {object} dto.ErrorDto "Error"
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, searchTerm, listRequestOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
|
||||
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, search string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -732,15 +732,20 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
// Join the clients so the search, the launch URL filter and the name sort can use their columns
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.UserAuthorizedOidcClient{}).
|
||||
Preload("Client").
|
||||
Where("user_id = ?", userID)
|
||||
Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id").
|
||||
Where("user_authorized_oidc_clients.user_id = ?", userID)
|
||||
|
||||
if search != "" {
|
||||
query = query.Where("LOWER(oidc_clients.name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
||||
}
|
||||
|
||||
// Apply the launch URL filter before pagination so hidden authorizations have their own page count
|
||||
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
|
||||
query = query.Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id")
|
||||
if hasLaunchURL {
|
||||
query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''")
|
||||
} else {
|
||||
@@ -748,6 +753,11 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
||||
}
|
||||
}
|
||||
|
||||
// The name lives on the joined client, so the generic sorting of the authorization model cannot handle it
|
||||
if listRequestOptions.Sort.Column == "name" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
|
||||
query = query.Order("LOWER(oidc_clients.name) " + utils.NormalizeSortDirection(listRequestOptions.Sort.Direction))
|
||||
}
|
||||
|
||||
var authorizedClients []model.UserAuthorizedOidcClient
|
||||
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
|
||||
|
||||
@@ -802,7 +812,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
|
||||
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, search string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -836,6 +846,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs)
|
||||
|
||||
if search != "" {
|
||||
query = query.Where("LOWER(oidc_clients.name) LIKE ?", "%"+strings.ToLower(search)+"%")
|
||||
}
|
||||
|
||||
// Apply the launch URL filter before pagination so the app launcher never contains empty pages
|
||||
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
|
||||
if hasLaunchURL {
|
||||
@@ -850,9 +864,11 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
// Handle custom sorting for lastUsedAt column
|
||||
var response utils.PaginationResponse
|
||||
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
|
||||
// Never used clients share a NULL timestamp, so they are ordered by name to keep pages stable
|
||||
query = query.
|
||||
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
|
||||
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
|
||||
Order("user_authorized_oidc_clients.last_used_at " + utils.NormalizeSortDirection(listRequestOptions.Sort.Direction) + " NULLS LAST").
|
||||
Order("LOWER(oidc_clients.name) ASC")
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -25,7 +26,7 @@ import (
|
||||
func TestListAuthorizedClientsRejectsMissingUser(t *testing.T) {
|
||||
service := &OidcService{db: testutils.NewDatabaseForTest(t)}
|
||||
|
||||
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", utils.ListRequestOptions{})
|
||||
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", "", utils.ListRequestOptions{})
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
}
|
||||
@@ -1040,11 +1041,11 @@ func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
}
|
||||
|
||||
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, utils.ListRequestOptions{})
|
||||
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"Unrestricted", "Restricted to user group"}, accessibleClientNames(groupClients))
|
||||
|
||||
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, utils.ListRequestOptions{})
|
||||
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
|
||||
}
|
||||
@@ -1079,22 +1080,22 @@ func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) {
|
||||
Filters: map[string][]any{"hasLaunchURL": {false}},
|
||||
}
|
||||
|
||||
allClients, allClientsPagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, utils.ListRequestOptions{})
|
||||
allClients, allClientsPagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), allClientsPagination.TotalItems)
|
||||
assert.ElementsMatch(t, []string{"Launchable", "Missing launch URL", "Empty launch URL"}, accessibleClientNames(allClients))
|
||||
|
||||
launchableClients, launchablePagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, withLaunchURL)
|
||||
launchableClients, launchablePagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, "", withLaunchURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), launchablePagination.TotalItems)
|
||||
assert.Equal(t, []string{"Launchable"}, accessibleClientNames(launchableClients))
|
||||
|
||||
allAuthorizations, allAuthorizationsPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, utils.ListRequestOptions{})
|
||||
allAuthorizations, allAuthorizationsPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, "", utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), allAuthorizationsPagination.TotalItems)
|
||||
assert.Len(t, allAuthorizations, 3)
|
||||
|
||||
hiddenAuthorizations, hiddenPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, withoutLaunchURL)
|
||||
hiddenAuthorizations, hiddenPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, "", withoutLaunchURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), hiddenPagination.TotalItems)
|
||||
assert.ElementsMatch(t, []string{"Missing launch URL", "Empty launch URL"}, []string{
|
||||
@@ -1103,6 +1104,138 @@ func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOidcService_ListAccessibleOidcClients_searchAndSort(t *testing.T) {
|
||||
forEachTestDatabase(t, func(t *testing.T, db *gorm.DB) {
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := model.User{Username: "search-and-sort"}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
clients := []model.OidcClient{
|
||||
{Name: "alpha"},
|
||||
{Name: "Bravo"},
|
||||
{Name: "charlie Cloud"},
|
||||
{Name: "Delta"},
|
||||
}
|
||||
for i := range clients {
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
}
|
||||
|
||||
// Only Bravo and Delta have been used, so the other two have no last used timestamp
|
||||
now := time.Now()
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: user.ID, ClientID: clients[1].ID, LastUsedAt: datatype.DateTime(now.Add(-time.Hour))}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: user.ID, ClientID: clients[3].ID, LastUsedAt: datatype.DateTime(now)}).Error)
|
||||
|
||||
list := func(search string, column string, direction string) []string {
|
||||
options := utils.ListRequestOptions{}
|
||||
options.Sort.Column = column
|
||||
options.Sort.Direction = direction
|
||||
result, _, err := s.ListAccessibleOidcClients(t.Context(), user.ID, search, options)
|
||||
require.NoError(t, err)
|
||||
return accessibleClientNames(result)
|
||||
}
|
||||
|
||||
t.Run("sorts by last used with never used clients last in name order", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Delta", "Bravo", "alpha", "charlie Cloud"}, list("", "lastUsedAt", "DESC"))
|
||||
})
|
||||
|
||||
t.Run("sorts by name case-insensitively", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"alpha", "Bravo", "charlie Cloud", "Delta"}, list("", "name", "asc"))
|
||||
assert.Equal(t, []string{"Delta", "charlie Cloud", "Bravo", "alpha"}, list("", "name", "desc"))
|
||||
})
|
||||
|
||||
t.Run("searches the name case-insensitively", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"charlie Cloud"}, list("CLOUD", "name", "asc"))
|
||||
assert.Equal(t, []string{"Delta"}, list("lTa", "name", "asc"))
|
||||
assert.Empty(t, list("missing", "name", "asc"))
|
||||
})
|
||||
|
||||
t.Run("combines search and sort", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Bravo", "charlie Cloud"}, list("r", "lastUsedAt", "desc"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestOidcService_ListAuthorizedClients_searchAndSort(t *testing.T) {
|
||||
forEachTestDatabase(t, func(t *testing.T, db *gorm.DB) {
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := model.User{Username: "authorized-search-and-sort"}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
launchURL := "https://launchable.example.com"
|
||||
clients := []model.OidcClient{
|
||||
{Name: "alpha"},
|
||||
{Name: "Bravo"},
|
||||
{Name: "charlie Cloud"},
|
||||
{Name: "Launchable", LaunchURL: &launchURL},
|
||||
}
|
||||
lastUsedOffsets := []time.Duration{-3 * time.Hour, -time.Hour, -2 * time.Hour, 0}
|
||||
now := time.Now()
|
||||
for i := range clients {
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: user.ID,
|
||||
ClientID: clients[i].ID,
|
||||
LastUsedAt: datatype.DateTime(now.Add(lastUsedOffsets[i])),
|
||||
}).Error)
|
||||
}
|
||||
|
||||
list := func(search string, hasLaunchURL *bool, column string, direction string) []string {
|
||||
options := utils.ListRequestOptions{}
|
||||
options.Sort.Column = column
|
||||
options.Sort.Direction = direction
|
||||
if hasLaunchURL != nil {
|
||||
options.Filters = map[string][]any{"hasLaunchURL": {*hasLaunchURL}}
|
||||
}
|
||||
result, _, err := s.ListAuthorizedClients(t.Context(), user.ID, search, options)
|
||||
require.NoError(t, err)
|
||||
names := make([]string, len(result))
|
||||
for i := range result {
|
||||
names[i] = result[i].Client.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
hidden := false
|
||||
|
||||
t.Run("sorts hidden clients by name case-insensitively", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"alpha", "Bravo", "charlie Cloud"}, list("", &hidden, "name", "asc"))
|
||||
assert.Equal(t, []string{"charlie Cloud", "Bravo", "alpha"}, list("", &hidden, "name", "desc"))
|
||||
})
|
||||
|
||||
t.Run("sorts hidden clients by last used", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Bravo", "charlie Cloud", "alpha"}, list("", &hidden, "lastUsedAt", "desc"))
|
||||
})
|
||||
|
||||
t.Run("searches the client name case-insensitively", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"charlie Cloud"}, list("cloud", &hidden, "name", "asc"))
|
||||
assert.Equal(t, []string{"Launchable"}, list("LAUNCH", nil, "name", "asc"))
|
||||
assert.Empty(t, list("launch", &hidden, "name", "asc"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// forEachTestDatabase runs the test on SQLite and Postgres because LIKE and NULL ordering behave differently on them
|
||||
func forEachTestDatabase(t *testing.T, test func(t *testing.T, db *gorm.DB)) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
test(t, testutils.NewDatabaseForTest(t))
|
||||
})
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
db := testutils.NewPostgresDatabaseForTest(t)
|
||||
|
||||
// Column types such as DateTime pick their encoding from the configured provider
|
||||
previousProvider := common.EnvConfig.DbProvider
|
||||
common.EnvConfig.DbProvider = common.DbProviderPostgres
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.DbProvider = previousProvider
|
||||
})
|
||||
|
||||
test(t, db)
|
||||
})
|
||||
}
|
||||
|
||||
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
|
||||
names := make([]string, len(clients))
|
||||
for i := range clients {
|
||||
|
||||
@@ -497,6 +497,13 @@
|
||||
"show_all_apps": "Show all apps",
|
||||
"show_hidden_apps": "Show hidden apps",
|
||||
"hide_all_apps": "Hide all apps",
|
||||
"sort_by": "Sort by",
|
||||
"recently_used": "Recently used",
|
||||
"name_a_to_z": "Name (A-Z)",
|
||||
"name_z_to_a": "Name (Z-A)",
|
||||
"search_apps": "Search apps",
|
||||
"no_apps_match_your_search": "No apps match your search",
|
||||
"try_a_different_search_term": "Try a different search term.",
|
||||
"revoke_access": "Revoke Access",
|
||||
"revoke_access_description": "Revoke access to {#b}{clientName}{/b}. {#b}{clientName}{/b} will no longer be able to access your account information.",
|
||||
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -86,3 +86,55 @@ test('Launch authorized client', async ({ page }) => {
|
||||
client.launchURL
|
||||
);
|
||||
});
|
||||
|
||||
test('Sort apps by name and remember the choice', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
const appCards = page.getByRole('article');
|
||||
const sortButton = page.getByRole('button', { name: 'Sort by' });
|
||||
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.nextcloud.name);
|
||||
|
||||
await sortButton.click();
|
||||
await page.getByRole('menuitemradio', { name: 'Name (A-Z)' }).click();
|
||||
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.immich.name);
|
||||
|
||||
await page.reload();
|
||||
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.immich.name);
|
||||
|
||||
await sortButton.click();
|
||||
await expect(page.getByRole('menuitemradio', { name: 'Name (A-Z)' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
await page.getByRole('menuitemradio', { name: 'Name (Z-A)' }).click();
|
||||
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.nextcloud.name);
|
||||
});
|
||||
|
||||
test('Search apps by name', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
const appCards = page.getByRole('article');
|
||||
const searchInput = page.getByRole('searchbox', { name: 'Search apps' });
|
||||
|
||||
await searchInput.fill('NEXT');
|
||||
await expect(appCards).toHaveCount(1);
|
||||
await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible();
|
||||
|
||||
await searchInput.fill('does-not-exist');
|
||||
await expect(page.getByText('No apps match your search')).toBeVisible();
|
||||
await expect(appCards).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(appCards).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('Search also filters hidden apps', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
await page.getByRole('button', { name: /Show all apps/ }).click();
|
||||
await expect(page.getByRole('article')).toHaveCount(4);
|
||||
|
||||
await page.getByRole('searchbox', { name: 'Search apps' }).fill(oidcClients.tailscale.name);
|
||||
await expect(page.getByRole('article')).toHaveCount(1);
|
||||
await expect(page.getByRole('article', { name: oidcClients.tailscale.name })).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user