diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index 72835b683..0b2fb9e76 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -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 () => { diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index c2df24f60..207d3b45c 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -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(); 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 (
@@ -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 })); + } + }} />
}> @@ -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 (
@@ -309,15 +317,22 @@ function UserTableHeader(props: {
@@ -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 { 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 { 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)); } diff --git a/apps/dashboard/src/components/export-users-dialog.tsx b/apps/dashboard/src/components/export-users-dialog.tsx index fba2eb435..6d9bc1838 100644 --- a/apps/dashboard/src/components/export-users-dialog.tsx +++ b/apps/dashboard/src/components/export-users-dialog.tsx @@ -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(