From 8842720eae78db0c99385c88cc43d9c7a99039dd Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 16 Jul 2025 22:47:09 -0600 Subject: [PATCH] Query request in line --- .../buster_rest/users/list/queryRequests.ts | 17 +- .../api/buster_rest/users/list/requests.ts | 2 +- apps/web/src/api/query_keys/users.ts | 3 +- .../ShareMenu/IndividualSharePerson.tsx | 18 +- .../features/ShareMenu/ShareMenuInvite.tsx | 203 +++++++++++------- .../SidebarUserFooter/SidebarUserFooter.tsx | 5 +- .../components/ui/avatar/AvatarUserButton.tsx | 26 +-- .../ui/inputs/InputSearchDropdown.tsx | 39 ++-- apps/web/src/components/ui/select/Select.tsx | 13 +- .../server-shared/src/user/request.types.ts | 12 +- pnpm-lock.yaml | 28 +-- 11 files changed, 207 insertions(+), 159 deletions(-) diff --git a/apps/web/src/api/buster_rest/users/list/queryRequests.ts b/apps/web/src/api/buster_rest/users/list/queryRequests.ts index 21776a8a8..ef77625e0 100644 --- a/apps/web/src/api/buster_rest/users/list/queryRequests.ts +++ b/apps/web/src/api/buster_rest/users/list/queryRequests.ts @@ -1,13 +1,24 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery, type UseQueryOptions } from '@tanstack/react-query'; import { userQueryKeys } from '@/api/query_keys/users'; import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { getUserToOrganization } from './requests'; +import type { GetUserToOrganizationResponse } from '@buster/server-shared/user'; +import type { RustApiError } from '../../errors'; -export const useGetUserToOrganization = (params: Parameters[0]) => { +export const useGetUserToOrganization = ( + params: Parameters[0], + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + > +) => { const queryFn = useMemoizedFn(() => getUserToOrganization(params)); return useQuery({ ...userQueryKeys.userGetUserToOrganization(params), - queryFn + placeholderData: keepPreviousData, + queryFn, + select: options?.select, + ...options }); }; diff --git a/apps/web/src/api/buster_rest/users/list/requests.ts b/apps/web/src/api/buster_rest/users/list/requests.ts index 14b20ba32..a7811340c 100644 --- a/apps/web/src/api/buster_rest/users/list/requests.ts +++ b/apps/web/src/api/buster_rest/users/list/requests.ts @@ -6,6 +6,6 @@ import { export const getUserToOrganization = async (payload: GetUserToOrganizationRequest) => { return mainApiV2 - .get('/users/organization', { params: payload }) + .get('/users', { params: payload }) .then((response) => response.data); }; diff --git a/apps/web/src/api/query_keys/users.ts b/apps/web/src/api/query_keys/users.ts index 28be8135c..3f0b91caf 100644 --- a/apps/web/src/api/query_keys/users.ts +++ b/apps/web/src/api/query_keys/users.ts @@ -58,7 +58,8 @@ const userGetUserDatasetGroups = (userId: string) => const userGetUserToOrganization = (params: GetUserToOrganizationRequest) => queryOptions({ - queryKey: ['users', 'organization', params] as const + queryKey: ['users', 'organization', params] as const, + staleTime: 10 * 1000 }); export const userQueryKeys = { diff --git a/apps/web/src/components/features/ShareMenu/IndividualSharePerson.tsx b/apps/web/src/components/features/ShareMenu/IndividualSharePerson.tsx index cc62bea1a..f78c288dd 100644 --- a/apps/web/src/components/features/ShareMenu/IndividualSharePerson.tsx +++ b/apps/web/src/components/features/ShareMenu/IndividualSharePerson.tsx @@ -4,6 +4,7 @@ import { Text } from '@/components/ui/typography'; import { useMemoizedFn } from '@/hooks'; import { AccessDropdown } from './AccessDropdown'; import type { ShareAssetType, ShareRole } from '@buster/server-shared/share'; +import { AvatarUserButton } from '../../ui/avatar/AvatarUserButton'; export const IndividualSharePerson: React.FC<{ name?: string; @@ -24,22 +25,7 @@ export const IndividualSharePerson: React.FC<{
-
-
- -
-
- - {name || email} - - - {isSameEmailName ? null : ( - - {email} - - )} -
-
+ = ({ - individualPermissions, - assetType, - assetId -}) => { - const { openErrorMessage } = useBusterNotifications(); +export const ShareMenuInvite: React.FC = React.memo( + ({ individualPermissions, assetType, assetId }) => { + const { openErrorMessage } = useBusterNotifications(); - const { mutateAsync: onShareMetric, isPending: isInvitingMetric } = useShareMetric(); - const { mutateAsync: onShareDashboard, isPending: isInvitingDashboard } = useShareDashboard(); - const { mutateAsync: onShareCollection, isPending: isInvitingCollection } = useShareCollection(); + const { mutateAsync: onShareMetric, isPending: isInvitingMetric } = useShareMetric(); + const { mutateAsync: onShareDashboard, isPending: isInvitingDashboard } = useShareDashboard(); + const { mutateAsync: onShareCollection, isPending: isInvitingCollection } = + useShareCollection(); - const [inputValue, setInputValue] = React.useState(''); - const [defaultPermissionLevel, setDefaultPermissionLevel] = React.useState('canView'); + const [inputValue, setInputValue] = React.useState(''); + const [defaultPermissionLevel, setDefaultPermissionLevel] = + React.useState('canView'); - const disableSubmit = !inputHasText(inputValue) || !isValidEmail(inputValue); - const isInviting = isInvitingMetric || isInvitingDashboard || isInvitingCollection; + const { data: usersData } = useGetUserToOrganization({ + user_name: inputValue, + email: inputValue, + page: 1, + page_size: 5 + }); - const onChangeAccessDropdown = useMemoizedFn((level: ShareRole | null) => { - if (level) setDefaultPermissionLevel(level); - }); + const disableSubmit = !inputHasText(inputValue) || !isValidEmail(inputValue); + const isInviting = isInvitingMetric || isInvitingDashboard || isInvitingCollection; - const onSubmitNewEmail = useMemoizedFn(async () => { - const emailIsValid = isValidEmail(inputValue); - if (!emailIsValid) { - openErrorMessage('Invalid email address'); - return; - } + const options: SelectItem[] = useMemo(() => { + return ( + usersData?.data.map((user) => ({ + label: ( + + ), + value: user.email + })) || [] + ); + }, [usersData]); - const isAlreadyShared = individualPermissions?.some( - (permission) => permission.email === inputValue - ); + const onChangeAccessDropdown = useMemoizedFn((level: ShareRole | null) => { + if (level) setDefaultPermissionLevel(level); + }); - if (isAlreadyShared) { - openErrorMessage('Email already shared'); - return; - } + const onSubmitNewEmail = useMemoizedFn(async () => { + const emailIsValid = isValidEmail(inputValue); + if (!emailIsValid) { + openErrorMessage('Invalid email address'); + return; + } - const payload: Parameters[0] = { - id: assetId, - params: [ - { - email: inputValue, - role: defaultPermissionLevel - } - ] - }; + const isAlreadyShared = individualPermissions?.some( + (permission) => permission.email === inputValue + ); - if (assetType === 'metric') { - await onShareMetric(payload); - } else if (assetType === 'dashboard') { - await onShareDashboard(payload); - } else if (assetType === 'collection') { - await onShareCollection(payload); - } + if (isAlreadyShared) { + openErrorMessage('Email already shared'); + return; + } - setInputValue(''); - }); + const payload: Parameters[0] = { + id: assetId, + params: [ + { + email: inputValue, + role: defaultPermissionLevel + } + ] + }; - return ( -
-
- {}} - onSearch={() => {}} - onPressEnter={() => {}} - value={inputValue} - onChange={setInputValue} - placeholder="Invite others by email..." - className="w-full" - /> + if (assetType === 'metric') { + await onShareMetric(payload); + } else if (assetType === 'dashboard') { + await onShareDashboard(payload); + } else if (assetType === 'collection') { + await onShareCollection(payload); + } - {inputValue && ( - { + onSubmitNewEmail(); + }); + + const onSelect = useMemoizedFn((value: string) => { + const associatedUser = usersData?.data.find((user) => user.email === value); + + if (associatedUser) { + setInputValue(associatedUser.email || ''); + } else { + setInputValue(value); + } + }); + + return ( +
+
+ - )} + + {inputValue && ( + + )} +
+
- -
- ); -}; + ); + } +); + +ShareMenuInvite.displayName = 'ShareMenuInvite'; diff --git a/apps/web/src/components/features/sidebars/SidebarUserFooter/SidebarUserFooter.tsx b/apps/web/src/components/features/sidebars/SidebarUserFooter/SidebarUserFooter.tsx index d10c5f1c9..8aec2da0f 100644 --- a/apps/web/src/components/features/sidebars/SidebarUserFooter/SidebarUserFooter.tsx +++ b/apps/web/src/components/features/sidebars/SidebarUserFooter/SidebarUserFooter.tsx @@ -42,7 +42,10 @@ export const SidebarUserFooter: React.FC = () => { username={name} email={email} avatarUrl={avatar_url} - className={cn(COLLAPSED_HIDDEN, 'w-full')} + className={cn( + COLLAPSED_HIDDEN, + 'hover:bg-item-hover active:bg-item-active w-full cursor-pointer' + )} />
diff --git a/apps/web/src/components/ui/avatar/AvatarUserButton.tsx b/apps/web/src/components/ui/avatar/AvatarUserButton.tsx index de09e345a..aa7dcb0b0 100644 --- a/apps/web/src/components/ui/avatar/AvatarUserButton.tsx +++ b/apps/web/src/components/ui/avatar/AvatarUserButton.tsx @@ -6,27 +6,27 @@ import { cn } from '@/lib/classMerge'; export const AvatarUserButton = React.forwardRef< HTMLDivElement, { - username?: string; + username?: string | null; avatarUrl?: string | null; - email?: string; + email?: string | null; className?: string; + avatarSize?: number; } ->(({ username, avatarUrl, email, className }, ref) => { +>(({ username, avatarUrl, email, className, avatarSize = 28 }, ref) => { + const isSameEmailName = email === username; + return ( -
- +
+
{username} - - {email} - + {!isSameEmailName && email && ( + + {email} + + )}
); diff --git a/apps/web/src/components/ui/inputs/InputSearchDropdown.tsx b/apps/web/src/components/ui/inputs/InputSearchDropdown.tsx index 37bd0ca37..abc8251b1 100644 --- a/apps/web/src/components/ui/inputs/InputSearchDropdown.tsx +++ b/apps/web/src/components/ui/inputs/InputSearchDropdown.tsx @@ -1,27 +1,25 @@ 'use client'; -import React, { useState, useMemo } from 'react'; -import { Select } from '../select/Select'; -import { useMemoizedFn } from '@/hooks'; +import React, { useMemo } from 'react'; +import { Select, type SelectItem } from '../select/Select'; +import { cn } from '@/lib/utils'; -export interface InputSearchDropdownProps { - options: { - label: string | React.ReactNode; - value: string; - }[]; - onSelect: (value: string) => void; +export interface InputSearchDropdownProps { + options: SelectItem[]; + onSelect: (value: T) => void; placeholder?: string; emptyMessage?: string | false; onSearch: ((value: string) => Promise) | ((value: string) => void); className?: string; disabled?: boolean; matchPopUpWidth?: boolean; - value?: string; + value: string; onChange?: (value: string) => void; onPressEnter: (value: string) => void; + loading?: boolean; } -export const InputSearchDropdown = ({ +export const InputSearchDropdown = ({ options, onSelect, onPressEnter, @@ -32,10 +30,9 @@ export const InputSearchDropdown = ({ value, className, onChange, + loading = false, disabled = false -}: InputSearchDropdownProps) => { - const [inputValue, setInputValue] = useState(value); - +}: InputSearchDropdownProps) => { const handleSearch = useMemo(() => { return { type: 'async' as const, @@ -45,14 +42,9 @@ export const InputSearchDropdown = ({ }; }, [onSearch]); - const handleChange = useMemoizedFn((value: string) => { - setInputValue(value); - onChange?.(value); - }); - return (