diff --git a/packages/backend/src/api/v1/filters.ts b/packages/backend/src/api/v1/filters.ts index e777976d..43c0f783 100644 --- a/packages/backend/src/api/v1/filters.ts +++ b/packages/backend/src/api/v1/filters.ts @@ -1,6 +1,7 @@ import sql from "@/src/utils/db"; import Router from "koa-router"; import { Context } from "koa"; +import { z } from "zod"; const filters = new Router({ prefix: "/filters", @@ -59,6 +60,12 @@ filters.get("/metadata", async (ctx: Context) => { filters.get("/users", async (ctx) => { const { projectId } = ctx.state; + const querySchema = z.object({ + limit: z.coerce.number().optional().default(3), + page: z.coerce.number().optional().default(0), + search: z.string().optional(), + }); + const { limit, page, search } = querySchema.parse(ctx.request.query); const rows = await sql` select @@ -67,6 +74,24 @@ filters.get("/users", async (ctx) => { external_user where project_id = ${projectId} + ${ + search + ? sql`and ( + external_id ilike ${"%" + search + "%"} + or props->>'email' ilike ${"%" + search + "%"} + or props->>'name' ilike ${"%" + search + "%"} + or props->>'firstName' ilike ${"%" + search + "%"} + or props->>'lastName' ilike ${"%" + search + "%"} + or props->>'orgId' ilike ${"%" + search + "%"} + )` + : sql`` + } + order by + external_id + limit + ${limit} + offset + ${page * limit} `; ctx.body = rows; diff --git a/packages/frontend/components/blocks/FacetedFilter.tsx b/packages/frontend/components/blocks/FacetedFilter.tsx index 34c6ff7d..76c15119 100644 --- a/packages/frontend/components/blocks/FacetedFilter.tsx +++ b/packages/frontend/components/blocks/FacetedFilter.tsx @@ -35,7 +35,7 @@ export default function FacetedCheck({ }) { const [search, setSearch] = useState(""); - const { project } = useProject(); + const { project: app } = useProject(); const prevAppIdRef = useRef(null); diff --git a/packages/frontend/components/checks/ChecksInputs.tsx b/packages/frontend/components/checks/ChecksInputs.tsx index 3632c2b2..1fdb6857 100644 --- a/packages/frontend/components/checks/ChecksInputs.tsx +++ b/packages/frontend/components/checks/ChecksInputs.tsx @@ -4,12 +4,14 @@ import SmartCheckSelect from "./SmartSelectInput"; import { DateTimePicker } from "@mantine/dates"; import { useEffect } from "react"; +import UserSelectInput from "./UserSelectInput"; const minDate = new Date(2021, 0, 1); const maxDate = new Date(); const CheckInputs = { select: SmartCheckSelect, + users: UserSelectInput, number: ({ placeholder, width, min, max, step, value, onChange, unit }) => { return ( diff --git a/packages/frontend/components/checks/UserSelectInput.tsx b/packages/frontend/components/checks/UserSelectInput.tsx new file mode 100644 index 00000000..8d6659a4 --- /dev/null +++ b/packages/frontend/components/checks/UserSelectInput.tsx @@ -0,0 +1,182 @@ +import React, { useContext, useState, useEffect } from "react"; +import useSWRInfinite from "swr/infinite"; +import { ProjectContext } from "@/utils/context"; +import { fetcher } from "@/utils/fetcher"; +import { + Combobox, + useCombobox, + TextInput, + Button, + ScrollArea, + PillsInput, + Pill, + Text, + Group, + CheckIcon, +} from "@mantine/core"; +import { parseAsArrayOf, parseAsInteger, useQueryState } from "nuqs"; +import AppUserAvatar from "../blocks/AppUserAvatar"; + +const PAGE_SIZE = 10; + +export default function UserSelectInput({ value, onChange, width }) { + const values = value || []; + const { projectId } = useContext(ProjectContext); + // search holds the term used for filtering. + const [search, setSearch] = useState(""); + // selectedUser holds the current selected user id (if any). + + const [selectedUsers, setSelectedUsers] = useQueryState>( + "users", + parseAsArrayOf(parseAsInteger).withDefault([]), + ); + + // SWRInfinite getKey now includes the search term. + const getKey = (pageIndex, previousPageData) => { + // Stop fetching if the previous page returned an empty array. + if (previousPageData && previousPageData.length === 0) return null; + return `/filters/users?limit=${PAGE_SIZE}&page=${pageIndex}&projectId=${projectId}&search=${encodeURIComponent( + search, + )}`; + }; + + const { data, error, size, setSize, mutate } = useSWRInfinite( + getKey, + fetcher.get, + ); + + // When the search term changes, clear the previous pages and reset pagination. + useEffect(() => { + // Clear cached pages to avoid mixing old and new results. + mutate([], false); + setSize(1); + }, [search, setSize, mutate]); + + // Determine various loading and pagination states. + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === "undefined"); + const isEmpty = data?.[0]?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE); + + // Flatten pages into a single list. + const users = data ? data.flat() : []; + + // Initialize the Mantine combobox store. + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex(), + }); + + // When an option is submitted, store its value and close the dropdown. + function handleValueSelect(val) { + // onChange( + // values.includes(val) ? values.filter((v) => v !== val) : [...values, val], + // ); + // setSelectedUser(val); + // combobox.closeDropdown(); + if (values.includes(val)) { + onChange(values.filter((v) => v !== val)); + } else { + onChange([...values, val]); + } + } + + function handleValueRemove(val) { + onChange(values.filter((v) => v !== val)); + } + + if (error) { + return
Error loading users.
; + } + + return ( + + + combobox.openDropdown()} + variant="unstyled" + size="xs" + miw={width} + w="min-content" + > + + + {values.length >= 4 ? ( + {values.length} selected + ) : ( + values.map((value) => ( + handleValueRemove(value)} + > + {value} + + )) + )} + + + + + + + setSearch(event.currentTarget.value)} + placeholder="Search..." + style={{ tope: 0, zIndex: 2, position: "sticky" }} + /> + + + !isReachingEnd && !isLoadingMore && setSize(size + 1) + } + > + + {users.length > 0 + ? users.map((user) => ( + + + {values.includes(user.id) ? ( + + ) : null} + + + + )) + : !isLoadingMore && ( + No users found + )} + + {isLoadingMore && ( + + Fetching... + + )} + + + + ); +} diff --git a/packages/frontend/pages/logs/index.tsx b/packages/frontend/pages/logs/index.tsx index 49e9eac9..a20fb1ed 100644 --- a/packages/frontend/pages/logs/index.tsx +++ b/packages/frontend/pages/logs/index.tsx @@ -547,7 +547,9 @@ export default function Logs() { { + setChecks(value); + }} restrictTo={(f) => CHECKS_BY_TYPE[type].includes(f.id)} /> diff --git a/packages/frontend/pages/test.tsx b/packages/frontend/pages/test.tsx new file mode 100644 index 00000000..a399e334 --- /dev/null +++ b/packages/frontend/pages/test.tsx @@ -0,0 +1,173 @@ +import React, { useContext, useState, useEffect } from "react"; +import useSWRInfinite from "swr/infinite"; +import { ProjectContext } from "@/utils/context"; +import { fetcher } from "@/utils/fetcher"; +import { + Combobox, + useCombobox, + TextInput, + Button, + ScrollArea, +} from "@mantine/core"; + +const PAGE_SIZE = 5; +export function useUserInfiniteSWR(key, search: string | null) { + const { projectId } = useContext(ProjectContext); + + function getKey(pageIndex, previousPageData) { + if (previousPageData && previousPageData.length === 0) return null; + return `/filters/users?limit=${PAGE_SIZE}&page=${pageIndex}&projectId=${projectId}${search ? `&search=${encodeURIComponent(search)}` : ""}`; + } + + const { data, error, size, setSize, mutate } = useSWRInfinite( + getKey, + fetcher.get, + ); + + useEffect(() => { + // When the search term changes, clear the previous pages and reset pagination. + mutate([], false); + setSize(1); + }, [search, setSize, mutate]); + + function loadMore() { + const isEmpty = data?.[0]?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE); + + if (!isReachingEnd) { + setSize(size + 1); + } + } + + return { + data, + error, + size, + setSize, + mutate, + loadMore, + }; +} + +function UserCombobox() { + const [search, setSearch] = useState(""); +} + +function UserComboboxOld() { + const { projectId } = useContext(ProjectContext); + // search holds the term used for filtering. + const [search, setSearch] = useState(""); + // selectedUser holds the current selected user id (if any). + const [selectedUser, setSelectedUser] = useState(""); + + // SWRInfinite getKey now includes the search term. + const getKey = (pageIndex, previousPageData) => { + // Stop fetching if the previous page returned an empty array. + if (previousPageData && previousPageData.length === 0) return null; + return `/filters/users?limit=${PAGE_SIZE}&page=${pageIndex}&projectId=${projectId}&search=${encodeURIComponent( + search, + )}`; + }; + + const { data, error, size, setSize, mutate } = useSWRInfinite( + getKey, + fetcher.get, + ); + + // When the search term changes, clear the previous pages and reset pagination. + useEffect(() => { + // Clear cached pages to avoid mixing old and new results. + mutate([], false); + setSize(1); + // Optionally clear any previous selection + setSelectedUser(""); + }, [search, setSize, mutate]); + + // Determine various loading and pagination states. + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === "undefined"); + const isEmpty = data?.[0]?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE); + + // Flatten pages into a single list. + const users = data ? data.flat() : []; + + // Initialize the Mantine combobox store. + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex(), + }); + + // When an option is submitted, store its value and close the dropdown. + const handleOptionSubmit = (val) => { + setSelectedUser(val); + combobox.closeDropdown(); + }; + + if (error) { + return
Error loading users.
; + } + + return ( + + {/* Combobox target: a TextInput that displays the search term or the selected value */} + + { + // Update the search term; this clears any previous selection. + setSearch(event.currentTarget.value); + setSelectedUser(""); + combobox.openDropdown(); + }} + onFocus={() => combobox.openDropdown()} + /> + + + + {/* Optional search input inside the dropdown */} + setSearch(event.currentTarget.value)} + placeholder="Search users..." + /> + + {/* Render the options list inside a scrollable area */} + + + {users.length > 0 ? ( + users.map((user) => ( + + {user.id} + + )) + ) : ( + No users found + )} + + + + {/* "Load More" button if more pages are available */} + {!isReachingEnd && ( +
+ +
+ )} +
+
+ ); +} + +export default UserCombobox; diff --git a/packages/shared/checks/index.ts b/packages/shared/checks/index.ts index 09217fed..e2a68ff0 100644 --- a/packages/shared/checks/index.ts +++ b/packages/shared/checks/index.ts @@ -266,7 +266,7 @@ export const CHECKS: Check[] = [ label: "Users", }, { - type: "select", + type: "users", multiple: true, width: 100, id: "users", diff --git a/packages/shared/checks/serialize.ts b/packages/shared/checks/serialize.ts index 4dd39391..e51b64e9 100644 --- a/packages/shared/checks/serialize.ts +++ b/packages/shared/checks/serialize.ts @@ -9,9 +9,12 @@ function paramSerializer(param: CheckParam, value: any) { return undefined; } switch (param.type) { + case "users": + return value.map((value: string) => encode(encode(value))).join(","); case "select": if (param.multiple) { if (!value.length) return undefined; + return value.map((value: string) => encode(encode(value))).join(","); } else { return encode(value); @@ -32,6 +35,10 @@ function deserializeParamValue( ): any | undefined { // Deserialize based on the filter parameter type switch (filterParam.type) { + case "users": + return value + .split(",") + .map((v) => decodeURIComponent(decodeURIComponent(v))); case "select": if (filterParam.multiple) { return value