Query request in line

This commit is contained in:
Nate Kelley 2025-07-16 22:47:09 -06:00
parent 969fdc6b18
commit 8842720eae
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
11 changed files with 207 additions and 159 deletions

View File

@ -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<typeof getUserToOrganization>[0]) => {
export const useGetUserToOrganization = <TData = GetUserToOrganizationResponse>(
params: Parameters<typeof getUserToOrganization>[0],
options?: Omit<
UseQueryOptions<GetUserToOrganizationResponse, RustApiError, TData>,
'queryKey' | 'queryFn'
>
) => {
const queryFn = useMemoizedFn(() => getUserToOrganization(params));
return useQuery({
...userQueryKeys.userGetUserToOrganization(params),
queryFn
placeholderData: keepPreviousData,
queryFn,
select: options?.select,
...options
});
};

View File

@ -6,6 +6,6 @@ import {
export const getUserToOrganization = async (payload: GetUserToOrganizationRequest) => {
return mainApiV2
.get<GetUserToOrganizationResponse>('/users/organization', { params: payload })
.get<GetUserToOrganizationResponse>('/users', { params: payload })
.then((response) => response.data);
};

View File

@ -58,7 +58,8 @@ const userGetUserDatasetGroups = (userId: string) =>
const userGetUserToOrganization = (params: GetUserToOrganizationRequest) =>
queryOptions<GetUserToOrganizationResponse>({
queryKey: ['users', 'organization', params] as const
queryKey: ['users', 'organization', params] as const,
staleTime: 10 * 1000
});
export const userQueryKeys = {

View File

@ -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<{
<div
className="flex h-8 items-center justify-between space-x-2 overflow-hidden"
data-testid={`share-person-${email}`}>
<div className="flex h-full items-center space-x-1.5 overflow-hidden">
<div className="flex h-full flex-col items-center justify-center">
<Avatar className="h-[24px] w-[24px]" name={name || email} image={avatar_url} />
</div>
<div className="flex flex-col space-y-0 overflow-hidden">
<Text truncate className="leading-1.3">
{name || email}
</Text>
{isSameEmailName ? null : (
<Text truncate size="xs" variant="tertiary" className="leading-1.3">
{email}
</Text>
)}
</div>
</div>
<AvatarUserButton username={name} email={email} avatarUrl={avatar_url} avatarSize={24} />
<AccessDropdown
shareLevel={role}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { InputSearchDropdown } from '@/components/ui/inputs/InputSearchDropdown';
import type { ShareAssetType, ShareConfig, ShareRole } from '@buster/server-shared/share';
import { AccessDropdown } from './AccessDropdown';
@ -10,6 +10,9 @@ import { useMemoizedFn } from '@/hooks';
import { useShareMetric } from '@/api/buster_rest/metrics';
import { useShareDashboard } from '@/api/buster_rest/dashboards';
import { useShareCollection } from '@/api/buster_rest/collections';
import { useGetUserToOrganization } from '../../../api/buster_rest/users';
import type { SelectItem } from '../../ui/select';
import { AvatarUserButton } from '../../ui/avatar/AvatarUserButton';
interface ShareMenuInviteProps {
assetType: ShareAssetType;
@ -17,96 +20,136 @@ interface ShareMenuInviteProps {
individualPermissions: ShareConfig['individual_permissions'];
}
export const ShareMenuInvite: React.FC<ShareMenuInviteProps> = ({
individualPermissions,
assetType,
assetId
}) => {
const { openErrorMessage } = useBusterNotifications();
export const ShareMenuInvite: React.FC<ShareMenuInviteProps> = 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<string>('');
const [defaultPermissionLevel, setDefaultPermissionLevel] = React.useState<ShareRole>('canView');
const [inputValue, setInputValue] = React.useState<string>('');
const [defaultPermissionLevel, setDefaultPermissionLevel] =
React.useState<ShareRole>('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<string>[] = useMemo(() => {
return (
usersData?.data.map((user) => ({
label: (
<AvatarUserButton
username={user.name}
email={user.email}
avatarUrl={user.avatarUrl}
avatarSize={24}
className="cursor-pointer p-0"
/>
),
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<typeof onShareMetric>[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<typeof onShareMetric>[0] = {
id: assetId,
params: [
{
email: inputValue,
role: defaultPermissionLevel
}
]
};
return (
<div className="flex h-full items-center space-x-2">
<div className="relative flex w-full items-center">
<InputSearchDropdown
options={[]}
onSelect={() => {}}
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 && (
<AccessDropdown
showRemove={false}
className="absolute top-[50%] right-[10px] -translate-y-1/2"
shareLevel={defaultPermissionLevel}
onChangeShareLevel={onChangeAccessDropdown}
assetType={assetType}
disabled={false}
setInputValue('');
});
const onPressEnter = useMemoizedFn((value: string) => {
onSubmitNewEmail();
});
const onSelect = useMemoizedFn((value: string) => {
const associatedUser = usersData?.data.find((user) => user.email === value);
if (associatedUser) {
setInputValue(associatedUser.email || '');
} else {
setInputValue(value);
}
});
return (
<div className="flex h-full items-center space-x-2">
<div className="relative flex w-full items-center">
<InputSearchDropdown
options={options}
onSelect={onSelect}
onChange={setInputValue}
onSearch={setInputValue}
onPressEnter={onPressEnter}
value={inputValue}
placeholder="Invite others by email..."
className="w-full"
/>
)}
{inputValue && (
<AccessDropdown
showRemove={false}
className="absolute top-[50%] right-[10px] -translate-y-1/2"
shareLevel={defaultPermissionLevel}
onChangeShareLevel={onChangeAccessDropdown}
assetType={assetType}
disabled={false}
/>
)}
</div>
<Button
loading={isInviting}
size={'tall'}
onClick={onSubmitNewEmail}
disabled={disableSubmit}>
Invite
</Button>
</div>
<Button
loading={isInviting}
size={'tall'}
onClick={onSubmitNewEmail}
disabled={disableSubmit}>
Invite
</Button>
</div>
);
};
);
}
);
ShareMenuInvite.displayName = 'ShareMenuInvite';

View File

@ -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'
)}
/>
</div>
<div className={cn(COLLAPSED_VISIBLE, 'items-center justify-center')}>

View File

@ -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 (
<div
ref={ref}
className={cn(
'hover:bg-item-hover active:bg-item-active flex w-full cursor-pointer items-center gap-x-2 rounded-md p-1',
className
)}>
<Avatar size={28} fallbackClassName="text-base" image={avatarUrl} name={username} />
<div ref={ref} className={cn('flex w-full items-center gap-x-2 rounded-md p-1', className)}>
<Avatar size={avatarSize} fallbackClassName="text-base" image={avatarUrl} name={username} />
<div className="flex w-full flex-col gap-y-0 overflow-hidden">
<Text truncate className="flex-grow">
{username}
</Text>
<Text truncate size={'sm'} variant={'secondary'}>
{email}
</Text>
{!isSameEmailName && email && (
<Text truncate size={'sm'} variant={'secondary'}>
{email}
</Text>
)}
</div>
</div>
);

View File

@ -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<T = string> {
options: SelectItem<T>[];
onSelect: (value: T) => void;
placeholder?: string;
emptyMessage?: string | false;
onSearch: ((value: string) => Promise<void>) | ((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 = <T extends string>({
options,
onSelect,
onPressEnter,
@ -32,10 +30,9 @@ export const InputSearchDropdown = ({
value,
className,
onChange,
loading = false,
disabled = false
}: InputSearchDropdownProps) => {
const [inputValue, setInputValue] = useState(value);
}: InputSearchDropdownProps<T>) => {
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 (
<Select
items={options}
items={value.length > 0 ? options : []}
placeholder={placeholder}
onChange={onSelect}
disabled={disabled}
@ -60,11 +52,14 @@ export const InputSearchDropdown = ({
className={className}
matchPopUpWidth={matchPopUpWidth}
emptyMessage={emptyMessage}
inputValue={inputValue}
onInputValueChange={handleChange}
inputValue={value}
onInputValueChange={onChange}
search={handleSearch}
hideChevron={true}
clearOnSelect={false}
loading={loading}
onPressEnter={onPressEnter}
type="input"
/>
);
};

View File

@ -57,6 +57,8 @@ interface BaseSelectProps<T> {
hideChevron?: boolean;
closeOnSelect?: boolean;
onPressEnter?: (value: string) => void;
type?: 'select' | 'input';
clearOnSelect?: boolean;
}
// Clearable version - onChange can return null
@ -149,6 +151,7 @@ function SelectComponent<T = string>({
onChange,
placeholder = 'Select an option',
emptyMessage = 'No options found.',
type = 'select',
value,
onOpenChange,
open: controlledOpen,
@ -164,6 +167,7 @@ function SelectComponent<T = string>({
onInputValueChange,
hideChevron = false,
onPressEnter,
clearOnSelect = true,
closeOnSelect = true
}: SelectProps<T>) {
const [internalInputValue, setInternalInputValue] = React.useState('');
@ -186,7 +190,7 @@ function SelectComponent<T = string>({
if (!newOpen) {
// Clear search value after 200ms to avoid flickering
setTimeout(() => {
setInputValue('');
if (clearOnSelect) setInputValue('');
setIsFocused(false);
}, 125);
}
@ -238,7 +242,7 @@ function SelectComponent<T = string>({
if (closeOnSelect) closePopover();
onChange?.(item.value);
handleOpenChange(false);
setInputValue('');
if (clearOnSelect) setInputValue('');
inputRef.current?.blur();
}
},
@ -401,10 +405,11 @@ function SelectComponent<T = string>({
readOnly={search === false}
className={cn(
'flex h-7 w-full items-center justify-between rounded border px-2.5 text-base',
'bg-background cursor-pointer transition-all duration-300',
'bg-background transition-all duration-300',
'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
disabled ? 'bg-disabled text-gray-light' : '',
!selectedItem && !currentInputValue && 'text-text-secondary'
!selectedItem && !currentInputValue && 'text-text-secondary',
type === 'input' ? 'cursor-text' : 'cursor-pointer'
)}
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">

View File

@ -51,14 +51,18 @@ export const GetUserListRequestSchema = z.object({
export type GetUserListRequest = z.infer<typeof GetUserListRequestSchema>;
export const GetUserToOrganizationRequestSchema = z.object({
page: z.coerce.number().min(1).default(1).optional(),
page_size: z.coerce.number().min(1).max(5000).default(25).optional(),
page: z.coerce.number().min(1).default(1),
page_size: z.coerce.number().min(1).max(5000).default(25),
user_name: z.string().optional(),
email: z.string().optional(),
//We need this because the frontend sends the roles as a comma-separated string in the query params
role: createOptionalQueryArrayPreprocessor(OrganizationRoleSchema).optional(),
//We need this because the frontend sends the status as a comma-separated string in the query params
status: createOptionalQueryArrayPreprocessor(OrganizationStatusSchema).optional(),
status: createOptionalQueryArrayPreprocessor(OrganizationStatusSchema)
.default(['active'])
.optional(),
});
export type GetUserToOrganizationRequest = z.infer<typeof GetUserToOrganizationRequestSchema>;
export type GetUserToOrganizationRequest = Partial<
z.infer<typeof GetUserToOrganizationRequestSchema>
>;

View File

@ -389,8 +389,8 @@ importers:
specifier: ^11.1.0
version: 11.1.0
framer-motion:
specifier: ^12.23.5
version: 12.23.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^12.23.6
version: 12.23.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
hono:
specifier: 'catalog:'
version: 4.8.4
@ -7304,8 +7304,8 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
framer-motion@12.23.5:
resolution: {integrity: sha512-t+6/f2TUowkr1gVuGwVwxR3ZQupCdCZj0mivG8M8CW2kwHPqtSePomECvmto15qoFCwost77O/XuEsq59MLDKw==}
framer-motion@12.23.6:
resolution: {integrity: sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@ -8738,11 +8738,11 @@ packages:
peerDependencies:
monaco-editor: '>=0.36'
motion-dom@12.23.5:
resolution: {integrity: sha512-RrUS4X11w7kIU1COoSVptuYTx1QQ/sViDEI1Yl1zL0nem8UXn3HRcsMrFTCkZSSRLXeVpN540bFP1iS87SicPQ==}
motion-dom@12.23.6:
resolution: {integrity: sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w==}
motion-utils@12.23.2:
resolution: {integrity: sha512-cIEXlBlXAOUyiAtR0S+QPQUM9L3Diz23Bo+zM420NvSd/oPQJwg6U+rT+WRTpp0rizMsBGQOsAwhWIfglUcZfA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
@ -19315,10 +19315,10 @@ snapshots:
forwarded@0.2.0: {}
framer-motion@12.23.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
framer-motion@12.23.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.23.5
motion-utils: 12.23.2
motion-dom: 12.23.6
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
@ -20991,11 +20991,11 @@ snapshots:
vscode-uri: 3.1.0
yaml: 2.8.0
motion-dom@12.23.5:
motion-dom@12.23.6:
dependencies:
motion-utils: 12.23.2
motion-utils: 12.23.6
motion-utils@12.23.2: {}
motion-utils@12.23.6: {}
mrmime@2.0.1: {}