Skip to content

Commit

Permalink
Add server-side paging to admin user table
Browse files Browse the repository at this point in the history
  • Loading branch information
myieye committed Oct 16, 2023
1 parent d18362a commit 6bc77b4
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 87 deletions.
13 changes: 13 additions & 0 deletions frontend/src/lib/components/Table/RefineFilterMessage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import t from '$lib/i18n';
export let total: number;
export let showing: number;
</script>

{#if showing < total}
<div class="text px-6 pt-4 text-secondary flex gap-2 items-center">
<span class="i-mdi-creation-outline text-lg" />
{$t('table.refine_filter', { remainingRows: total - showing })}
</div>
{/if}
1 change: 1 addition & 0 deletions frontend/src/lib/components/Table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as RefineFilterMessage } from './RefineFilterMessage.svelte'
3 changes: 3 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,5 +326,8 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia
},
"notifications": {
"update_detected": "A new version of the application was detected. You may need to reload the page.",
},
"table": {
"refine_filter": "Refine your filter to see {remainingRows} more rows..."
}
}
7 changes: 3 additions & 4 deletions frontend/src/lib/util/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,11 @@ export function getBoolSearchParam<T extends PrimitiveRecord>(key: keyof Conditi
}
}

export function getSearchParam<T extends PrimitiveRecord, R>(
export function getSearchParam<T extends PrimitiveRecord, R = string | undefined>(
key: keyof ConditionalPick<T, (R extends StandardEnum<unknown> ? R[keyof R] : R)> & string,
params: URLSearchParams,
defaultValue?: EnumOrString<R>): EnumOrString<R> | undefined {
params: URLSearchParams): EnumOrString<R> | undefined {
const value = params.get(key);
return value ? value as EnumOrString<R> | undefined : defaultValue;
return value as EnumOrString<R> | undefined;
}

type EnumOrString<R> = R extends StandardEnum<unknown> ? R : string;
56 changes: 21 additions & 35 deletions frontend/src/routes/(authenticated)/admin/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,29 @@
import { Duration } from '$lib/util/time';
import { Icon } from '$lib/icons';
import Dropdown from '$lib/components/Dropdown.svelte';
import Button from '$lib/forms/Button.svelte';
import type { User } from './+page';
import { RefineFilterMessage } from '$lib/components/Table';
import type { AdminUserSearchParams, User } from './+page';
import ProjectTable from './ProjectTable.svelte';
import { getSearchParams, queryParam } from '$lib/util/query-params';
export let data: PageData;
$: allProjects = data.projects;
$: allUsers = data.users;
$: userData = data.users;
const {queryParams} = getSearchParams<AdminUserSearchParams>({
userSearch: queryParam.string<string>(''),
userEmail: queryParam.string(undefined),
});
$: users = $userData?.items ?? [];
$: totalUsers = $userData?.totalCount ?? 0;
$: showUsers = $queryParams.userSearch ? users : users.slice(0, 10);
let projectsTable: ProjectTable;
function filterProjectsByUser(user: User): void {
projectsTable.setUserFilter(user.email);
$queryParams.userEmail = user.email;
}
let projectsTable: ProjectTable;
let deleteUserModal: DeleteUserModal;
let formModal: EditUserAccount;
Expand Down Expand Up @@ -54,24 +63,6 @@
}
}
const defaultFilterLimit = 100;
let userSearch = '';
$: userSearchLower = userSearch.toLocaleLowerCase();
let userSearchLimit = defaultFilterLimit;
$: userLimit = userSearch ? userSearchLimit : 10;
$: filteredUsers = $allUsers?.items?.filter(
(u) =>
!userSearch ||
u.name.toLocaleLowerCase().includes(userSearchLower) ||
u.email.toLocaleLowerCase().includes(userSearchLower)
) ?? [];
$: users = filteredUsers.slice(0, userLimit);
$: {
// Reset limit if search is changed
userSearch;
userSearchLimit = defaultFilterLimit;
}
</script>

Expand All @@ -80,20 +71,20 @@
</svelte:head>
<main>
<div class="grid lg:grid-cols-2 grid-cols-1 gap-10">
<ProjectTable bind:this={projectsTable} projects={$allProjects} users={$allUsers?.items ?? []} {defaultFilterLimit}/>
<ProjectTable bind:this={projectsTable} projects={$allProjects} />

<div>
<span class="text-xl flex gap-4">
{$t('admin_dashboard.user_table_title')}
<Badge>
<span class="inline-flex gap-2">
{userSearch ? filteredUsers.length : users.length}
{showUsers.length}
<span>/</span>
{$allUsers?.totalCount}
{totalUsers}
</span>
</Badge>
</span>
<Input label="" placeholder={$t('admin_dashboard.filter_placeholder')} bind:value={userSearch} />
<Input label="" placeholder={$t('admin_dashboard.filter_placeholder')} bind:value={$queryParams.userSearch} />

<div class="divider" />
<div class="overflow-x-auto">
Expand All @@ -110,7 +101,7 @@
</tr>
</thead>
<tbody>
{#each users as user}
{#each showUsers as user}
<tr>
<td>{user.name}</td>
<td>
Expand Down Expand Up @@ -157,12 +148,7 @@
{/each}
</tbody>
</table>
{#if userSearch && userSearchLimit < filteredUsers.length}
<Button class="float-right mt-2"
on:click={() => (userSearchLimit = Infinity)}>
{$t('admin_dashboard.load_more')}
</Button>
{/if}
<RefineFilterMessage total={totalUsers} showing={showUsers.length} />
</div>
</div>
</div>
Expand Down
70 changes: 50 additions & 20 deletions frontend/src/routes/(authenticated)/admin/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,70 @@ import { getClient, graphql } from '$lib/gql';
import type { PageLoadEvent } from './$types';
import { isAdmin, type LexAuthUser } from '$lib/user';
import { redirect } from '@sveltejs/kit';
import { getBoolSearchParam } from '$lib/util/query-params';
import type { $OpResult, ChangeUserAccountByAdminInput, ChangeUserAccountByAdminMutation, ProjectType } from '$lib/gql/types';
import type {LoadAdminDashboardQuery} from '$lib/gql/types';
import { getBoolSearchParam, getSearchParam } from '$lib/util/query-params';
import type { $OpResult, ChangeUserAccountByAdminInput, ChangeUserAccountByAdminMutation, ProjectFilterInput, ProjectType } from '$lib/gql/types';
import type { LoadAdminDashboardProjectsQuery, LoadAdminDashboardUsersQuery } from '$lib/gql/types';

export type AdminSearchParams = {
export const _FILTER_PAGE_SIZE = 100;

export type AdminProjectSearchParams = {
showDeletedProjects: boolean,
projectType: ProjectType | undefined,
userEmail: string | undefined,
projectSearch: string,
};

export type Project = LoadAdminDashboardQuery['projects'][number];
export type User = LoadAdminDashboardQuery['users']['items'][number];
export type AdminUserSearchParams = {
userSearch: string,
userEmail: string | undefined,
}

export type AdminSearchParams = AdminProjectSearchParams & AdminUserSearchParams;

export type Project = LoadAdminDashboardProjectsQuery['projects'][number];
export type User = NonNullable<NonNullable<LoadAdminDashboardUsersQuery['users']>['items']>[number];

export async function load(event: PageLoadEvent) {
const parentData = await event.parent();
requireAdmin(parentData.user);

const withDeletedProjects = getBoolSearchParam<AdminSearchParams>('showDeletedProjects', event.url.searchParams);
const userSearch = getSearchParam<AdminSearchParams>('userSearch', event.url.searchParams) ?? '';
const userEmail = getSearchParam<AdminSearchParams>('userEmail', event.url.searchParams);

const client = getClient();

const projectFilter = {
...(userEmail ? { users: { some: { user: { email: { icontains: userEmail } } } } } : {})
} as ProjectFilterInput;

//language=GraphQL
const results = await client.queryStore(event.fetch, graphql(`
query loadAdminDashboard($withDeletedProjects: Boolean) {
projects(orderBy: [
const projectResultsPromise = client.queryStore(event.fetch, graphql(`
query loadAdminDashboardProjects($withDeletedProjects: Boolean, $filter: ProjectFilterInput) {
projects(
where: $filter,
orderBy: [
{lastCommit: ASC},
{name: ASC}
], withDeleted: $withDeletedProjects) {
code
id
name
lastCommit
type
deletedDate
userCount
code
id
name
lastCommit
type
deletedDate
userCount
}
users(orderBy: {name: ASC}, take: 100) {
}
`), { withDeletedProjects, filter: projectFilter });

const userResultsPromise = client.queryStore(event.fetch, graphql(`
query loadAdminDashboardUsers($userSearch: String, $take: Int!) {
users(
where: {or: [
{name: {icontains: $userSearch}},
{email: {icontains: $userSearch}}
]}, orderBy: {name: ASC}, take: $take) {
totalCount
items {
id
Expand All @@ -55,10 +82,13 @@ export async function load(event: PageLoadEvent) {
}
}
}
`), { withDeletedProjects });
`), { userSearch, take: _FILTER_PAGE_SIZE });

const [projectResults, userResults] = await Promise.all([projectResultsPromise, userResultsPromise]);

return {
...results
...projectResults,
...userResults,
}
}

Expand Down Expand Up @@ -91,6 +121,6 @@ export async function _changeUserAccountByAdmin(input: ChangeUserAccountByAdminI
}
`),
{ input: input }
)
)
return result;
}
11 changes: 5 additions & 6 deletions frontend/src/routes/(authenticated)/admin/EditUserAccount.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
import { TrashIcon } from '$lib/icons';
import { z } from 'zod';
import Input from '$lib/forms/Input.svelte';
import { UserRole, type LoadAdminDashboardQuery } from '$lib/gql/types';
import { _changeUserAccountByAdmin } from './+page';
import { UserRole } from '$lib/gql/types';
import { _changeUserAccountByAdmin, type User } from './+page';
import { hash, type LexAuthUser } from '$lib/user';
import t from '$lib/i18n';
import type { FormModalResult } from '$lib/components/modals/FormModal.svelte';
import { Button, SystemRoleSelect } from '$lib/forms';
import { passwordFormRules } from '$lib/forms/utils';
export let currUser: LexAuthUser;
export let deleteUser: (user: UserRow) => void;
type UserRow = LoadAdminDashboardQuery['users'][0];
export let deleteUser: (user: User) => void;
const schema = z.object({
email: z.string().email(),
Expand All @@ -29,8 +28,8 @@
formModal.close();
}
let _user: UserRow;
export async function openModal(user: UserRow): Promise<FormModalResult<Schema>> {
let _user: User;
export async function openModal(user: User): Promise<FormModalResult<Schema>> {
_user = user;
const role = user.isAdmin ? UserRole.Admin : UserRole.User;
return await formModal.open({ name: user.name, email: user.email, role }, async () => {
Expand Down
33 changes: 11 additions & 22 deletions frontend/src/routes/(authenticated)/admin/ProjectTable.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
<script lang="ts">
import t from '$lib/i18n';
import {getProjectTypeI18nKey, ProjectTypeIcon} from '$lib/components/ProjectType';
import type {AdminSearchParams, Project, User} from './+page';
import {_FILTER_PAGE_SIZE, type AdminProjectSearchParams, type Project} from './+page';
import {_deleteProject} from '$lib/gql/mutations';
import {DialogResponse} from '$lib/components/modals';
import {notifyWarning} from '$lib/notify';
Expand All @@ -19,37 +19,25 @@ import AuthenticatedUserIcon from '$lib/icons/AuthenticatedUserIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import {bubbleFocusOnDestroy} from '$lib/util/focus';
import Button from '$lib/forms/Button.svelte';
export let projects: Project[];
export let users: User[];
export function setUserFilter(email: string): void {
$queryParams.userEmail = email;
}
export let defaultFilterLimit: number;
const {queryParams, defaultQueryParams} = getSearchParams<AdminSearchParams>({
const {queryParams, defaultQueryParams} = getSearchParams<AdminProjectSearchParams>({
showDeletedProjects: queryParam.boolean<boolean>(false),
projectType: queryParam.string<ProjectType | undefined>(undefined),
userEmail: queryParam.string(undefined),
projectSearch: queryParam.string<string>(''),
});
function getFilteredUser(userEmail: string | undefined): User | undefined {
if (!userEmail) {
return undefined;
}
if (filteredUser?.email == userEmail) {
return filteredUser;
}
return users.find(user => user.email === userEmail);
}
let projectFilterLimit = _FILTER_PAGE_SIZE;
let hasActiveProjectFilter: boolean;
$: projectSearchLower = $queryParams.projectSearch.toLocaleLowerCase();
let projectFilterLimit = defaultFilterLimit;
$: projectLimit = hasActiveProjectFilter ? projectFilterLimit : 10;
$: filteredUser = getFilteredUser($queryParams.userEmail);
$: userProjects = filteredUser?.projects.map(({projectId}) => projects.find(p => p.id === projectId) as Project);
$: filteredProjects = (userProjects ?? projects).filter(
$: filteredProjects = projects.filter(
(p) =>
(!$queryParams.projectSearch ||
p.name.toLocaleLowerCase().includes(projectSearchLower) ||
Expand All @@ -59,7 +47,7 @@ $: shownProjects = filteredProjects.slice(0, projectLimit);
$: {
// Reset limit if search is changed
hasActiveProjectFilter;
projectFilterLimit = defaultFilterLimit;
projectFilterLimit = _FILTER_PAGE_SIZE;
}
let deleteProjectModal: ConfirmDeleteModal;
Expand All @@ -73,16 +61,17 @@ async function softDeleteProject(project: Project): Promise<void> {
}
}
</script>

<ConfirmDeleteModal bind:this={deleteProjectModal} i18nScope="delete_project_modal"/>
<div>
<div class="flex justify-between items-center">
<span class="text-xl flex gap-4">
{$t('admin_dashboard.project_table_title')}
<Badge>
<span class="inline-flex gap-2">
{hasActiveProjectFilter ? filteredProjects.length : shownProjects.length}
<span>/</span>
{projects.length}
{shownProjects.length}
<span>/</span>
{filteredProjects.length}
</span>
</Badge>
</span>
Expand Down

0 comments on commit 6bc77b4

Please sign in to comment.