Skip to content

Commit

Permalink
Add advanced filtering options to the Users table (#653)
Browse files Browse the repository at this point in the history
### Summary & Motivation

Enable filtering in the Users table with options for free-text search,
role, status, and creation date. Users can now be searched by name,
email, or title using an always-visible free-text search field. A "Show
all filters" button reveals additional dropdowns for filtering by
`UserRole` (Member, Admin, Owner), `UserStatus` (Active, Pending), and a
date range picker for filtering by user creation date.

Filters are reflected in the URL (e.g.,
`/admin/users?search=cto&userStatus=Active&userRole=Owner&startDate=2024-12-01&endDate=2024-12-31`),
enabling easy sharing of filtered views. When filters are present in the
URL, the page automatically renders with all filters visible.

The API endpoint and Users repository have been extended to support
filtering by user status (Active/Inactive based on `User.EmailVerified`)
and user creation dates.

### Checklist

- [x] I have added a Label to the pull-request
- [x] I have added tests, and done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Dec 30, 2024
2 parents 80ad8b0 + e45af0c commit fed6228
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ public interface IUserRepository : ICrudRepository<User, UserId>
Task<(User[] Users, int TotalItems, int TotalPages)> Search(

Check warning on line 21 in application/account-management/Core/Features/Users/Domain/UserRepository.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Method has 10 parameters, which is greater than the 7 authorized. (https://rules.sonarsource.com/csharp/RSPEC-107)

Check warning on line 21 in application/account-management/Core/Features/Users/Domain/UserRepository.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Method has 10 parameters, which is greater than the 7 authorized. (https://rules.sonarsource.com/csharp/RSPEC-107)
string? search,
UserRole? userRole,
UserStatus? userStatus,
DateTimeOffset? startDate,
DateTimeOffset? endDate,
SortableUserProperties? orderBy,
SortOrder? sortOrder,
int? pageSize,
int? pageOffset,
int? pageSize,
CancellationToken cancellationToken
);
}
Expand Down Expand Up @@ -73,10 +76,13 @@ public Task<int> CountTenantUsersAsync(TenantId tenantId, CancellationToken canc
public async Task<(User[] Users, int TotalItems, int TotalPages)> Search(

Check warning on line 76 in application/account-management/Core/Features/Users/Domain/UserRepository.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 76 in application/account-management/Core/Features/Users/Domain/UserRepository.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
string? search,
UserRole? userRole,
UserStatus? userStatus,
DateTimeOffset? startDate,
DateTimeOffset? endDate,
SortableUserProperties? orderBy,
SortOrder? sortOrder,
int? pageSize,
int? pageOffset,
int? pageSize,
CancellationToken cancellationToken
)
{
Expand All @@ -97,6 +103,22 @@ CancellationToken cancellationToken
users = users.Where(u => u.Role == userRole);
}

if (userStatus is not null)
{
var active = userStatus == UserStatus.Active;
users = users.Where(u => u.EmailConfirmed == active);
}

if (startDate is not null)
{
users = users.Where(u => u.CreatedAt >= startDate);
}

if (endDate is not null)
{
users = users.Where(u => u.CreatedAt < endDate.Value.AddDays(1));
}

users = orderBy switch
{
SortableUserProperties.CreatedAt => sortOrder == SortOrder.Ascending
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ public enum UserRole
Owner
}

[PublicAPI]
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UserStatus
{
Active,
Pending
}

[PublicAPI]
public enum SortableUserProperties
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Queries;
public sealed record GetUsersQuery(
string? Search = null,
UserRole? UserRole = null,
UserStatus? UserStatus = null,
DateTimeOffset? StartDate = null,
DateTimeOffset? EndDate = null,
SortableUserProperties OrderBy = SortableUserProperties.Name,
SortOrder SortOrder = SortOrder.Ascending,
int PageSize = 25,
int? PageOffset = null
int? PageOffset = null,
int PageSize = 25
) : IRequest<Result<GetUsersResponse>>;

[PublicAPI]
Expand Down Expand Up @@ -53,10 +56,13 @@ public async Task<Result<GetUsersResponse>> Handle(GetUsersQuery query, Cancella
var (users, count, totalPages) = await userRepository.Search(
query.Search,
query.UserRole,
query.UserStatus,
query.StartDate,
query.EndDate,
query.OrderBy,
query.SortOrder,
query.PageSize,
query.PageOffset,
query.PageSize,
cancellationToken
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,158 @@
import { ListFilterIcon } from "lucide-react";
import { useState } from "react";
import { FilterIcon, FilterXIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@repo/ui/components/Button";
import { DateRangePicker } from "@repo/ui/components/DateRangePicker";
import { SearchField } from "@repo/ui/components/SearchField";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { type SortableUserProperties, type SortOrder, UserRole, UserStatus } from "@/shared/lib/api/client";
import { Select, SelectItem } from "@repo/ui/components/Select";
import { getUserRoleLabel } from "@/shared/lib/api/userRole";
import { getUserStatusLabel } from "@/shared/lib/api/userStatus";
import { parseDate, type DateValue } from "@internationalized/date";

// SearchParams interface defines the structure of URL query parameters
interface SearchParams {
search: string | undefined;
userRole: UserRole | undefined;
userStatus: UserStatus | undefined;
startDate: string | undefined;
endDate: string | undefined;
orderBy: SortableUserProperties | undefined;
sortOrder: SortOrder | undefined;
pageOffset: number | undefined;
}

type DateRange = { start: DateValue; end: DateValue } | null;

/**
* UserQuerying component handles the user list filtering.
* Uses URL parameters as the single source of truth for all filters.
* The only local state is for the search input, which is debounced
* to prevent too many URL updates while typing.
*/
export function UserQuerying() {
const [searchTerm, setSearchTerm] = useState<string>("");
const navigate = useNavigate();
const searchParams = (useLocation().search as SearchParams) ?? {};
const [search, setSearch] = useState<string | undefined>(searchParams.search);
const [showAllFilters, setShowAllFilters] = useState(
Boolean(searchParams.userRole ?? searchParams.userStatus ?? searchParams.startDate ?? searchParams.endDate)
);

// Convert URL date strings to DateRange if they exist
const dateRange =
searchParams.startDate && searchParams.endDate
? {
start: parseDate(searchParams.startDate),
end: parseDate(searchParams.endDate)
}
: null;

// Updates URL parameters while preserving existing ones
const updateFilter = useCallback(
(params: Partial<SearchParams>) => {
navigate({
to: "/admin/users",
search: (prev) => ({
...prev,
...params,
pageOffset: prev.pageOffset === 0 ? undefined : prev.pageOffset
})
});
},
[navigate]
);

// Debounce search updates to avoid too many URL changes while typing
useEffect(() => {
const timeoutId = setTimeout(() => {
updateFilter({ search: (search as string) || undefined });
}, 500);

return () => clearTimeout(timeoutId);
}, [search, updateFilter]);

return (
<div className="flex justify-between mt-4 mb-4 gap-2">
<div className="flex items-center mt-4 mb-4 gap-2">
<SearchField
placeholder={t`Search`}
value={searchTerm}
onChange={setSearchTerm}
aria-label={t`Users`}
value={search}
onChange={setSearch}
label={t`Search`}
autoFocus
className="min-w-[240px]"
/>

<Button variant="secondary">
<ListFilterIcon size={16} />
<Trans>Filters</Trans>
{showAllFilters && (
<>
<Select
selectedKey={searchParams.userRole}
onSelectionChange={(userRole) => {
updateFilter({ userRole: (userRole as UserRole) || undefined });
}}
label={t`User Role`}
placeholder={t`Any role`}
className="w-[150px]"
>
<SelectItem id="">
<Trans>Any role</Trans>
</SelectItem>
{Object.values(UserRole).map((userRole) => (
<SelectItem id={userRole} key={userRole}>
{getUserRoleLabel(userRole)}
</SelectItem>
))}
</Select>

<Select
selectedKey={searchParams.userStatus}
onSelectionChange={(userStatus) => {
updateFilter({ userStatus: (userStatus as UserStatus) || undefined });
}}
label={t`User Status`}
placeholder={t`Any status`}
className="w-[150px]"
>
<SelectItem id="">
<Trans>Any status</Trans>
</SelectItem>
{Object.values(UserStatus).map((userStatus) => (
<SelectItem id={userStatus} key={userStatus}>
{getUserStatusLabel(userStatus)}
</SelectItem>
))}
</Select>

<DateRangePicker
value={dateRange}
onChange={(range) => {
updateFilter({
startDate: range?.start.toString() || undefined,
endDate: range?.end.toString() || undefined
});
}}
label={t`Creation date`}
/>
</>
)}

<Button
variant="secondary"
className={showAllFilters ? "h-10 w-10 p-0 mt-6" : "mt-6"}
onPress={() => {
if (showAllFilters) {
// Reset filters when hiding
updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined });
}
setShowAllFilters(!showAllFilters);
}}
>
{showAllFilters ? (
<FilterXIcon size={16} aria-label={t`Hide filters`} />
) : (
<FilterIcon size={16} aria-label={t`Show filters`} />
)}
</Button>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ type UserDetails = components["schemas"]["UserDetails"];

export function UserTable() {
const navigate = useNavigate();
const { orderBy, pageOffset, sortOrder } = useSearch({ strict: false });
const { search, userRole, userStatus, startDate, endDate, orderBy, sortOrder, pageOffset } = useSearch({
strict: false
});
const userInfo = useUserInfo();

const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>(() => ({
Expand All @@ -35,9 +37,14 @@ export function UserTable() {
const { data } = useApi("/api/account-management/users", {
params: {
query: {
PageOffset: pageOffset,
Search: search,
UserRole: userRole,
UserStatus: userStatus,
StartDate: startDate,
EndDate: endDate,
OrderBy: orderBy,
SortOrder: sortOrder
SortOrder: sortOrder,
PageOffset: pageOffset
}
},
key: refreshKey
Expand Down Expand Up @@ -66,9 +73,9 @@ export function UserTable() {
to: "/admin/users",
search: (prev) => ({
...prev,
pageOffset: undefined,
orderBy: (newSortDescriptor.column?.toString() ?? "Name") as SortableUserProperties,
sortOrder: newSortDescriptor.direction === "ascending" ? SortOrder.Ascending : SortOrder.Descending
sortOrder: newSortDescriptor.direction === "ascending" ? SortOrder.Ascending : SortOrder.Descending,
pageOffset: undefined
})
});
},
Expand Down Expand Up @@ -160,7 +167,7 @@ export function UserTable() {

<div className="flex flex-col gap-2 h-full w-full">
<Table
key={`${orderBy}-${sortOrder}`}
key={`${search}-${userRole}-${userStatus}-${startDate}-${endDate}-${orderBy}-${sortOrder}`}
selectionMode="multiple"
selectionBehavior="toggle"
sortDescriptor={sortDescriptor}
Expand All @@ -175,7 +182,7 @@ export function UserTable() {
<Trans>Email</Trans>
</Column>
<Column minWidth={65} defaultWidth={110} allowsSorting id={SortableUserProperties.CreatedAt}>
<Trans>Added</Trans>
<Trans>Created</Trans>
</Column>
<Column minWidth={65} defaultWidth={120} allowsSorting id={SortableUserProperties.ModifiedAt}>
<Trans>Last Seen</Trans>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { UserQuerying } from "./-components/UserQuerying";
import { UserTable } from "./-components/UserTable";
import { SharedSideMenu } from "@/shared/components/SharedSideMenu";
import { SortableUserProperties, SortOrder } from "@/shared/lib/api/client";
import { SortableUserProperties, SortOrder, UserRole, UserStatus } from "@/shared/lib/api/client";
import { z } from "zod";
import { TopMenu } from "@/shared/components/topMenu";
import { Breadcrumb } from "@repo/ui/components/Breadcrumbs";
Expand All @@ -13,9 +13,14 @@ import InviteUserModal from "./-components/InviteUserModal";
import { Trans } from "@lingui/react/macro";

const userPageSearchSchema = z.object({
pageOffset: z.number().default(0).optional(),
search: z.string().optional(),
userRole: z.nativeEnum(UserRole).nullable().optional(),
userStatus: z.nativeEnum(UserStatus).nullable().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
orderBy: z.nativeEnum(SortableUserProperties).default(SortableUserProperties.Name).optional(),
sortOrder: z.nativeEnum(SortOrder).default(SortOrder.Ascending).optional()
sortOrder: z.nativeEnum(SortOrder).default(SortOrder.Ascending).optional(),
pageOffset: z.number().default(0).optional()
});

export const Route = createFileRoute("/admin/users/")({
Expand Down
Loading

0 comments on commit fed6228

Please sign in to comment.