"Only anonymous" users filter
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

This commit is contained in:
Konstantin Wohlwend 2026-04-03 09:54:29 -07:00
parent acbacbd53f
commit 73f68c831f
3 changed files with 60 additions and 35 deletions

View File

@ -45,7 +45,8 @@ export default function PageClient() {
search?: string,
includeRestricted: boolean,
includeAnonymous: boolean,
}>({ includeRestricted: false, includeAnonymous: false });
onlyAnonymous: boolean,
}>({ includeRestricted: false, includeAnonymous: false, onlyAnonymous: false });
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = async () => {

View File

@ -54,6 +54,7 @@ type QueryState = {
search?: string,
includeRestricted: boolean,
includeAnonymous: boolean,
onlyAnonymous: boolean,
page: number,
pageSize: number,
cursor?: string,
@ -114,6 +115,7 @@ const COLUMN_LAYOUT: ColumnLayoutMap = {
const DEFAULT_QUERY_STATE: QueryState = {
includeRestricted: true,
includeAnonymous: false,
onlyAnonymous: false,
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
signedUpOrder: "desc",
@ -155,6 +157,10 @@ const querySchema = yup.object({
.boolean()
.transform((_, originalValue) => (originalValue === "true" ? true : undefined))
.optional(),
onlyAnonymous: yup
.boolean()
.transform((_, originalValue) => (originalValue === "true" ? true : undefined))
.optional(),
page: yup
.number()
.transform(numberTransform)
@ -180,7 +186,7 @@ const querySchema = yup.object({
const columnHelper = createColumnHelper<ExtendedServerUser>();
export function UserTable(props?: {
onFilterChange?: (filters: { search?: string, includeRestricted: boolean, includeAnonymous: boolean }) => void,
onFilterChange?: (filters: { search?: string, includeRestricted: boolean, includeAnonymous: boolean, onlyAnonymous: boolean }) => void,
}) {
const { query, setQuery } = useUserTableQueryState();
const [searchInput, setSearchInput] = useState(query.search ?? "");
@ -209,7 +215,7 @@ export function UserTable(props?: {
useEffect(() => {
cursorPaginationCache.resetCache();
}, [cursorPaginationCache, query.search, query.includeRestricted, query.includeAnonymous, query.pageSize, query.signedUpOrder]);
}, [cursorPaginationCache, query.search, query.includeRestricted, query.includeAnonymous, query.onlyAnonymous, query.pageSize, query.signedUpOrder]);
useEffect(() => {
if (query.page > 1 && !query.cursor) {
@ -224,8 +230,9 @@ export function UserTable(props?: {
search: query.search,
includeRestricted: query.includeRestricted,
includeAnonymous: query.includeAnonymous,
onlyAnonymous: query.onlyAnonymous,
});
}, [query.search, query.includeRestricted, query.includeAnonymous, onFilterChange]);
}, [query.search, query.includeRestricted, query.includeAnonymous, query.onlyAnonymous, onFilterChange]);
return (
<section className="space-y-2">
@ -233,13 +240,19 @@ export function UserTable(props?: {
searchValue={searchInput}
onSearchChange={setSearchInput}
includeRestricted={query.includeRestricted}
onIncludeRestrictedChange={(value) =>
setQuery((prev) => ({ ...prev, includeRestricted: value, page: 1, cursor: undefined }))
}
includeAnonymous={query.includeAnonymous}
onIncludeAnonymousChange={(value) =>
setQuery((prev) => ({ ...prev, includeAnonymous: value, page: 1, cursor: undefined }))
}
onlyAnonymous={query.onlyAnonymous}
onFilterModeChange={(value) => {
if (value === "anonymous-only") {
setQuery((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: true, onlyAnonymous: true, page: 1, cursor: undefined }));
} else if (value === "anonymous") {
setQuery((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: true, onlyAnonymous: false, page: 1, cursor: undefined }));
} else if (value === "restricted") {
setQuery((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: false, onlyAnonymous: false, page: 1, cursor: undefined }));
} else {
setQuery((prev) => ({ ...prev, includeRestricted: false, includeAnonymous: false, onlyAnonymous: false, page: 1, cursor: undefined }));
}
}}
/>
<div className="overflow-clip rounded-xl border border-border bg-card">
<Suspense fallback={<UserTableSkeleton pageSize={query.pageSize} />}>
@ -258,30 +271,25 @@ function UserTableHeader(props: {
searchValue: string,
onSearchChange: (value: string) => void,
includeRestricted: boolean,
onIncludeRestrictedChange: (value: boolean) => void,
includeAnonymous: boolean,
onIncludeAnonymousChange: (value: boolean) => void,
onlyAnonymous: boolean,
onFilterModeChange: (value: "standard" | "restricted" | "anonymous" | "anonymous-only") => void,
}) {
const { searchValue, onSearchChange, includeRestricted, onIncludeRestrictedChange, includeAnonymous, onIncludeAnonymousChange } = props;
const {
searchValue,
onSearchChange,
includeRestricted,
includeAnonymous,
onlyAnonymous,
onFilterModeChange,
} = props;
// Determine the current filter state
// "standard" = only fully onboarded users
// "restricted" = include restricted users
// "anonymous" = include anonymous users (which also includes restricted)
const filterValue = includeAnonymous ? "anonymous" : includeRestricted ? "restricted" : "standard";
const handleFilterChange = (value: string) => {
if (value === "anonymous") {
onIncludeAnonymousChange(true);
onIncludeRestrictedChange(true); // anonymous also includes restricted
} else if (value === "restricted") {
onIncludeAnonymousChange(false);
onIncludeRestrictedChange(true);
} else {
onIncludeAnonymousChange(false);
onIncludeRestrictedChange(false);
}
};
// "anonymous-only" = include only anonymous users
const filterValue = onlyAnonymous ? "anonymous-only" : includeAnonymous ? "anonymous" : includeRestricted ? "restricted" : "standard";
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@ -309,15 +317,22 @@ function UserTableHeader(props: {
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Select
value={filterValue}
onValueChange={handleFilterChange}
onValueChange={(value) => {
if (value === "standard" || value === "restricted" || value === "anonymous" || value === "anonymous-only") {
onFilterModeChange(value);
return;
}
throw new Error(`Unexpected user filter mode: ${value}`);
}}
>
<SelectTrigger className="w-[210px]" aria-label="User list filter">
<SelectTrigger className="w-[240px]" aria-label="User list filter">
<SelectValue placeholder="Signups" />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="standard">Exclude restricted users</SelectItem>
<SelectItem value="restricted">Signups</SelectItem>
<SelectItem value="anonymous">Signups & anonymous users</SelectItem>
<SelectItem value="anonymous-only">Only anonymous users</SelectItem>
</SelectContent>
</Select>
</div>
@ -375,9 +390,13 @@ function UserTableBody(props: {
() => createUserColumns(setQuery, query.signedUpOrder === "desc"),
[setQuery, query.signedUpOrder],
);
const displayedUsers = useMemo(
() => (query.onlyAnonymous ? users.filter((user) => user.isAnonymous) : users),
[users, query.onlyAnonymous],
);
const table = useReactTable({
data: users,
data: displayedUsers,
columns,
getCoreRowModel: getCoreRowModel(),
});
@ -400,7 +419,7 @@ function UserTableBody(props: {
variant="outline"
onClick={() => {
resetCache();
setQuery({ search: undefined, includeRestricted: true, includeAnonymous: false, page: 1, cursor: undefined });
setQuery({ search: undefined, includeRestricted: true, includeAnonymous: false, onlyAnonymous: false, page: 1, cursor: undefined });
}}
>
Reset filters
@ -688,7 +707,8 @@ function normalizeDateValue(value: Date | string | null | undefined) {
function sanitizeQueryState(state: Partial<QueryState>): QueryState {
const search = state.search?.trim() ? state.search.trim() : undefined;
const includeAnonymous = Boolean(state.includeAnonymous);
const onlyAnonymous = Boolean(state.onlyAnonymous);
const includeAnonymous = onlyAnonymous || Boolean(state.includeAnonymous);
// Default to including restricted users; also enforce that anonymous implies restricted
const includeRestricted = includeAnonymous || (state.includeRestricted ?? true);
const candidatePageSize = state.pageSize ?? DEFAULT_PAGE_SIZE;
@ -697,7 +717,7 @@ function sanitizeQueryState(state: Partial<QueryState>): QueryState {
const page = Number.isFinite(candidatePage) ? Math.max(1, Math.floor(candidatePage)) : 1;
const cursor = page > 1 && state.cursor ? state.cursor : undefined;
const signedUpOrder = state.signedUpOrder === "asc" ? "asc" : "desc";
return { search, includeRestricted, includeAnonymous, page, pageSize, cursor, signedUpOrder };
return { search, includeRestricted, includeAnonymous, onlyAnonymous, page, pageSize, cursor, signedUpOrder };
}
function serializeQueryState(state: QueryState) {
@ -712,6 +732,9 @@ function serializeQueryState(state: QueryState) {
if (state.includeAnonymous) {
params.set("includeAnonymous", "true");
}
if (state.onlyAnonymous) {
params.set("onlyAnonymous", "true");
}
if (state.page > 1) {
params.set("page", String(state.page));
}

View File

@ -37,6 +37,7 @@ type ExportField = {
type ExportOptions = {
search?: string,
includeAnonymous: boolean,
onlyAnonymous?: boolean,
};
const DEFAULT_FIELDS: ExportField[] = [
@ -273,7 +274,7 @@ async function fetchAllUsers(
limit,
cursor,
query: options?.search,
includeAnonymous: options?.includeAnonymous ?? true,
includeAnonymous: options?.onlyAnonymous ? true : (options?.includeAnonymous ?? true),
orderBy: "signedUpAt",
desc: true,
});
@ -282,7 +283,7 @@ async function fetchAllUsers(
cursor = batch.nextCursor ?? undefined;
} while (cursor);
return allUsers;
return options?.onlyAnonymous ? allUsers.filter((user) => user.isAnonymous) : allUsers;
}
function transformUserData(